diff --git a/.claude/agents/spec-refactorer.md b/.claude/agents/spec-refactorer.md deleted file mode 100644 index d7114e86b..000000000 --- a/.claude/agents/spec-refactorer.md +++ /dev/null @@ -1,234 +0,0 @@ ---- -name: spec-refactorer -description: | - Refactors test files in TurboHTTP.Tests and TurboHTTP.StreamTests: - - Removes [Trait("RFC", ...)] from non-Protocol folders (only Protocol tests need RFC traceability) - - Validates RFC trait section references against the Obsidian vault (notes/RFC/) - - Removes /// XML doc comments outside method bodies (class-level and method-level docs) - - Validates Spec naming conventions (BDD names, sealed classes, no DisplayName, etc.) - Trigger phrases: "refactor specs", "clean up specs", "spec refactor", "spec cleanup". -tools: - - Read - - Edit - - Glob - - Grep - - Bash - - mcp__obsidian__search_notes - - mcp__obsidian__read_note - - mcp__obsidian__list_directory ---- - -You are the Spec refactoring agent for the TurboHTTP project. -You clean up test files in component-based folders by removing unnecessary RFC traits, -validating RFC section references against the Obsidian vault, removing XML doc comments -outside method bodies, and validating naming conventions. - -## Folder Classification - -RFC `[Trait]` attributes are only meaningful for tests that exercise Protocol-layer code. - -| Category | Folders | RFC traits | -|----------|---------|-----------| -| **Protocol** | `Http10/`, `Http11/`, `Http2/`, `Http3/`, `Caching/`, `Cookies/`, `AltSvc/`, `Semantics/` | **Keep** | -| **Non-Protocol** | `Transport/`, `Security/`, `Diagnostics/`, `Hosting/`, `Streams/`, `Client/`, `Internal/` | **Remove** | - -Both `TurboHTTP.Tests/` and `TurboHTTP.StreamTests/` follow this classification. - -## What to Do - -### 1. `[Trait("RFC", ...)]` in non-Protocol folders — REMOVE - -Remove the entire `[Trait("RFC", "...")]` line (including trailing newline) from any test -file located in a non-Protocol folder. Do NOT remove traits from Protocol folders. - -### 2. RFC Trait Section Validation — VERIFY against Obsidian vault - -For every `[Trait("RFC", "RFC{number}-{section}")]` that remains (Protocol folders), -validate that the referenced section actually exists in the Obsidian vault. - -#### Vault structure - -RFC notes live under `notes/RFC/RFC{number}/`: -``` -notes/RFC/RFC9114/ -├── RFC9114.md (index with section table) -└── sections/ - ├── 13_7_1_frame_layout.md (§7.1 — frontmatter: rfc_section: "7.1") - ├── 14_7_2_frame_definitions.md (§7.2 — contains ### 7.2.1 ... ### 7.2.7 headings) - └── ... -``` - -#### How to validate a trait reference - -Given `[Trait("RFC", "RFC9204-2.1")]`: - -1. **Extract** RFC number (`9204`) and section (`2.1`) -2. **Glob** `notes/RFC/RFC9204/sections/*.md` -3. **Match level 1–2 sections** (e.g., `2`, `2.1`): find a section file whose frontmatter - `rfc_section` matches the trait section. Each section file has: - ```yaml - rfc_section: "2.1" - ``` -4. **Match level 3+ sections** (e.g., `4.2.1`, `5.2.2.3`): the parent section file covers the - major.minor (e.g., `4.2`). Read that file and check for a `###` heading that starts with the - full sub-section number: - ```markdown - ### 4.2.1 Calculating Freshness Lifetime - ``` -5. **Report mismatches**: - - `RFC_NOT_FOUND` — no `notes/RFC/RFC{number}/` directory exists - - `SECTION_NOT_FOUND` — no section file with matching `rfc_section` frontmatter - - `SUBSECTION_NOT_FOUND` — parent section file exists but no heading for the sub-section - -#### Validation output - -``` -=== RFC TRAIT VALIDATION === -File Line Trait Status -src/TurboHTTP.Tests/Http3/FooSpec.cs 14 RFC9114-7.1 ✅ valid -src/TurboHTTP.Tests/Http3/FooSpec.cs 28 RFC9114-7.2.1 ✅ valid -src/TurboHTTP.Tests/Http3/FooSpec.cs 42 RFC9114-99.1 ❌ SECTION_NOT_FOUND -src/TurboHTTP.Tests/Caching/BarSpec.cs 10 RFC9999-1 ❌ RFC_NOT_FOUND -``` - -#### Caching during scan - -Build a lookup cache to avoid redundant Obsidian reads: -- Cache 1: `RFC{number}` → list of section files (glob once per RFC) -- Cache 2: `RFC{number}-{major.minor}` → frontmatter `rfc_section` values (read once per file) -- Cache 3: `RFC{number}-{major.minor}` → set of `###` heading section numbers (read once per file) - -### 3. `///` XML doc comments outside method bodies — REMOVE - -Remove ALL `///` comment lines that appear **outside** method bodies. This includes: - -- **Class-level XML docs** — `/// `, `/// `, `/// `, etc. -- **Method-level XML docs** — `/// RFC 9114 §7 — Empty DATA frame` above `[Fact]` - -**Preserve** any `//` or `///` comments that are **inside** method bodies (brace depth >= 2). - -#### How to detect inside vs outside - -Track brace depth as you scan line by line: -- Depth 0 = file/namespace level -- Depth 1 = class level (between class `{` and `}`) -- Depth 2+ = inside a method, property, or nested block - -A `///` comment at depth 0 or 1 is **outside** → remove it. -A `///` comment at depth 2+ is **inside** → keep it. - -**Important:** Ignore braces inside string literals and comments when counting depth. - -### 4. Clean up blank lines - -After removing comments, collapse consecutive blank lines into at most one blank line. - -## Naming Convention Validation (report only) - -While scanning files, also check these conventions and **report** violations (do not auto-fix): - -| Rule | Check | -|------|-------| -| R1 | File name ends in `Spec.cs`, no numeric prefix | -| R2 | Class is `sealed`, ends in `Spec` | -| R3 | BDD method names: `Subject_should_behavior()` or `Subject_should_behavior_when_condition()` | -| R4 | `[Fact]` has no `DisplayName` | -| R5 | `[Theory]` has no `DisplayName` | -| R6 | RFC Trait format (if present in Protocol folder): `RFC\d{4}(-[\d.]+)?` | -| R7 | All tests have `Timeout`| - -## Workflow - -### Phase 1 — Discover - -Glob for all `*.cs` files under component-based folders in both test projects: - -``` -src/TurboHTTP.Tests/{Http10,Http11,Http2,Http3,Semantics,Caching,Cookies,AltSvc,Transport,Security,Diagnostics,Hosting,Streams,Client,Internal}/**/*.cs -src/TurboHTTP.StreamTests/{Http10,Http11,Http2,Http3,Semantics,Caching,Cookies,Streams,Transport}/**/*.cs -``` - -Categorize each file as Protocol or non-Protocol based on its folder. - -### Phase 2 — Analyze - -For each file: -1. Read the full file content -2. Track brace depth to identify `///` comments outside method bodies -3. If non-Protocol folder: find `[Trait("RFC", ...)]` lines → mark for removal -4. If Protocol folder: collect all `[Trait("RFC", ...)]` values → validate in Phase 3 -5. Check naming conventions (Rules R1–R7) -6. Record all findings with file path and line numbers - -### Phase 3 — Validate RFC References - -For each unique RFC number found in Phase 2: -1. Glob `notes/RFC/RFC{number}/sections/*.md` to get available section files -2. Read frontmatter of each section file to build a `rfc_section` → file mapping -3. For sub-section traits (e.g., `RFC9111-4.2.1`), read the parent section file and - extract `###` heading numbers - -For each trait reference, look it up in the cache: -- Section `X` or `X.Y` → match against frontmatter `rfc_section` values -- Section `X.Y.Z` or deeper → match against `###` headings in the `X.Y` parent file - -Report all mismatches as `SECTION_NOT_FOUND` or `RFC_NOT_FOUND`. - -### Phase 4 — Dry-Run Report - -Output a grouped report: - -``` -=== RFC TRAITS TO REMOVE (non-Protocol folders) === -File Line Current -src/TurboHTTP.Tests/Transport/FooSpec.cs 14 [Trait("RFC", "RFC9112-6")] - -=== RFC TRAIT VALIDATION (Protocol folders) === -File Line Trait Status -src/TurboHTTP.Tests/Http3/BarSpec.cs 14 RFC9114-7.1 ✅ -src/TurboHTTP.Tests/Http3/BarSpec.cs 42 RFC9114-99.1 ❌ SECTION_NOT_FOUND - -=== XML DOC COMMENTS TO REMOVE === -File Lines Preview -src/TurboHTTP.Tests/Caching/BarSpec.cs 5-13 /// RFC 9111 §4.4 ... - -=== NAMING VIOLATIONS (report only) === -File Line Rule Detail -src/TurboHTTP.Tests/Http2/BazSpec.cs 45 R3 Method uses PascalCase after subject -``` - -Then ask the user for confirmation before proceeding to Phase 5. - -### Phase 5 — Apply Changes - -For each file with findings: -1. Read the file -2. Build the list of line ranges to remove (trait lines + doc comment blocks) -3. Use Edit to remove those lines -4. Collapse consecutive blank lines - -Process files in batches using parallel Edit calls where possible. - -**RFC validation mismatches are reported but NOT auto-fixed** — the user decides whether to -correct the section reference or remove the trait. - -### Phase 6 — Summary - -``` -Files scanned : N -Files modified : N -RFC traits removed : N (non-Protocol) -RFC traits validated : N (Protocol) - ✅ valid : N - ❌ invalid : N -Doc comments removed : N lines across M files -Naming violations : N (reported, not fixed) -``` - -## Safety - -- Always show the dry-run report before applying changes -- Never remove comments inside method bodies -- Never modify method signatures, attributes (except Trait removal), or code -- RFC validation mismatches are reported, not auto-fixed -- After all edits, suggest running `dotnet build` to verify compilation diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 958327455..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "slopwatch analyze -d $(git rev-parse --show-toplevel) --hook", - "timeout": 60000 - } - ] - } - ] - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1461366d7..07ffe741a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,8 @@ name: Build & Test on: - push: - branches: ["main"] - paths-ignore: - - "docs/**" - - "notes/**" - - "*.md" - - ".github/workflows/docs.yml" - - ".github/workflows/commitlint.yml" - - ".github/workflows/codeql.yml" - - ".github/workflows/release.yml" pull_request: - branches: ["main"] + branches: ["main", "release-next"] paths-ignore: - "docs/**" - "notes/**" @@ -40,13 +30,17 @@ permissions: jobs: build-and-test: runs-on: ubuntu-latest - if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore(main): release')" + strategy: + fail-fast: false + matrix: + backend: [kestrel, docker] steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 1 + submodules: true - name: Install libmsquic (QUIC/HTTP3 support) run: | @@ -72,8 +66,10 @@ jobs: --no-restore ${{ env.PROJECT_PATH }} - - name: Run tests + - name: Run tests (backend=${{ matrix.backend }}) working-directory: "./src" + env: + TURBOHTTP_TEST_BACKEND: ${{ matrix.backend }} run: > dotnet test --configuration Release diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6a06b70d6..18b039ce1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,6 +52,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + submodules: true # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index f79bbff04..402581d7d 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -2,7 +2,7 @@ name: PR & Commit Lint on: pull_request: - branches: ["main"] + branches: ["main", "release-next"] types: [opened, edited, synchronize] permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99441465d..c16c1680d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release & Publish on: push: - branches: [ "main" ] + branches: [ "main", "release-next" ] env: GLOBAL_JSON_PATH: "./src/global.json" @@ -28,6 +28,7 @@ jobs: id: release uses: googleapis/release-please-action@v5 with: + target-branch: ${{ github.ref_name }} config-file: release-please-config.json manifest-file: .release-please-manifest.json @@ -42,6 +43,7 @@ jobs: with: fetch-depth: 1 lfs: 'true' + submodules: true - name: Install .NET uses: actions/setup-dotnet@v5 @@ -75,7 +77,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: nuget-packages - path: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/*.nupkg + path: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/TurboHTTP.*.nupkg nuget-publish: runs-on: ubuntu-latest @@ -95,7 +97,7 @@ jobs: - name: Push to NuGet.org run: > dotnet nuget push - ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/*.nupkg + ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/TurboHTTP.*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_SECRET }} --skip-duplicate @@ -104,12 +106,12 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.release-please.outputs.tag_name }} - files: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/*.nupkg + files: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/TurboHTTP.*.nupkg docs-build: runs-on: ubuntu-latest needs: release-please - if: needs.release-please.outputs.release_created == 'true' + if: needs.release-please.outputs.release_created == 'true' && github.ref == 'refs/heads/main' defaults: run: working-directory: docs @@ -120,6 +122,7 @@ jobs: with: fetch-depth: 0 lfs: 'true' + submodules: true - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.gitignore b/.gitignore index f6e488803..1da1ab496 100644 --- a/.gitignore +++ b/.gitignore @@ -373,4 +373,5 @@ TurboHTTP/.obsidian/ .maggus/logs/ .maggus/worktrees/ -*.ps1 \ No newline at end of file +*.ps1 +TURBOHTTP_GAP_ANALYSIS.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..d1edd6db0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/servus.akka"] + path = lib/servus.akka + url = https://github.com/Bavaria-Black/servus.akka.git diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 895bf0e35..a76776929 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.0.0" + ".": "3.0.0-alpha.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f8274e57a..45fc42b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,203 @@ # Changelog +## [3.0.0-alpha.2](https://github.com/Leberkas-org/TurboHTTP/compare/v3.0.0-alpha.1...v3.0.0-alpha.2) (2026-06-11) + + +### Features + +* **client:** default timeout for channel path + CancelPendingRequests drain ([fd4bf5e](https://github.com/Leberkas-org/TurboHTTP/commit/fd4bf5e4b1b57029e6907bc8537c61fc0cf4a505)) +* **client:** expose pipe buffer tuning via TurboClientOptions ([9773bab](https://github.com/Leberkas-org/TurboHTTP/commit/9773bab0a19d58905d3ef209ef3b9045c9c9641d)) +* **client:** propagate effective CancellationToken onto request options ([9602238](https://github.com/Leberkas-org/TurboHTTP/commit/9602238d85911e8a846e4f1cf4d5092a7dfd1d2e)) +* **h10:** per-request cancellation with disconnect ([6b51616](https://github.com/Leberkas-org/TurboHTTP/commit/6b516168f3403b7eaf8a506b895065cad1a81e93)) +* **h11:** per-request cancellation with pipelining awareness ([3b01383](https://github.com/Leberkas-org/TurboHTTP/commit/3b01383d2ef45ec000054a66ee72e35c8dd97aed)) +* **h2:** emit RST_STREAM on per-request cancellation ([323097d](https://github.com/Leberkas-org/TurboHTTP/commit/323097d26ec2c21fd40c53ad6c9beb08793d2e3a)) +* **h3:** emit STOP_SENDING on per-request cancellation ([0cbe4d9](https://github.com/Leberkas-org/TurboHTTP/commit/0cbe4d9a19476c4daf461c7a0988aa66df0b45d2)) +* pipe transport, body redesign, server simplification ([5281774](https://github.com/Leberkas-org/TurboHTTP/commit/528177468b6dc7d9cf0dcbe514af70641f37190b)) +* **protocol:** add CancellationToken infrastructure for per-request cancel ([e74d9d2](https://github.com/Leberkas-org/TurboHTTP/commit/e74d9d2bea936d953c929ddcea761e5967302036)) +* **server:** Add transport buffer options ([d2cc47f](https://github.com/Leberkas-org/TurboHTTP/commit/d2cc47f3b5a2eb2a3b7e008ed5cfc5314f0d2baa)) +* **server:** expose TransportBufferOptions with protocol-optimized defaults ([3857fc9](https://github.com/Leberkas-org/TurboHTTP/commit/3857fc9cc3883e7be455979d29c7a694923451cf)) +* **stage:** register per-request CancellationToken callbacks in connection stage ([bcb808e](https://github.com/Leberkas-org/TurboHTTP/commit/bcb808e6d807a88be870adcdadc390a29349849c)) + + +### Bug Fixes + +* **bench:** add 30s timeout guard to all benchmark iterations ([82b1cb7](https://github.com/Leberkas-org/TurboHTTP/commit/82b1cb71ee96daf4937d9f916bb2860283772123)) +* **bench:** add 30s timeout to all benchmark clients ([48fe358](https://github.com/Leberkas-org/TurboHTTP/commit/48fe358330a8d3761c4686c07b7231fb82af95a5)) +* **bench:** add CancellationToken timeout to all warmup and SendAsync calls ([b9cd7e0](https://github.com/Leberkas-org/TurboHTTP/commit/b9cd7e034aaecaa125bc96865f07bb0874995770)) +* **bench:** add IterationCleanup drain for streaming benchmarks ([a7be4f4](https://github.com/Leberkas-org/TurboHTTP/commit/a7be4f4ec3e9f225f524da05d4e439badf462991)) +* **bench:** align H3 client MaxConcurrentStreams with Kestrel default ([564e753](https://github.com/Leberkas-org/TurboHTTP/commit/564e753ac04e3366723406a07728849468239b95)) +* **bench:** drain stale responses at start of each streaming iteration ([be01236](https://github.com/Leberkas-org/TurboHTTP/commit/be012368484f05b7be77a32e099cc43fff5dcfd7)) +* **bench:** drop CL=4096 from streaming benchmarks ([564968a](https://github.com/Leberkas-org/TurboHTTP/commit/564968a16dae2ded49a253ff99c43dc448409f6d)) +* **benchmarks:** protocol-aware fan-out limits and scaled timeouts ([575375e](https://github.com/Leberkas-org/TurboHTTP/commit/575375ee8b08f863ed46e5290dc5beb2ce05c5cc)) +* **bench:** prevent benchmark reports from overwriting previous runs ([fa5d3bd](https://github.com/Leberkas-org/TurboHTTP/commit/fa5d3bdaf01c0a57db9811f873359a7430f6b41f)) +* **bench:** prevent streaming benchmark deadlocks ([a249088](https://github.com/Leberkas-org/TurboHTTP/commit/a249088e8a601f72e6900abdccf74ea83a612c6b)) +* **bench:** raise QUIC stream limit and harden streaming benchmarks ([9a54267](https://github.com/Leberkas-org/TurboHTTP/commit/9a54267077668f468a03ad24ea69c7d4bb6cefe9)) +* **bench:** restore CL=4096 for streaming benchmarks ([a02761a](https://github.com/Leberkas-org/TurboHTTP/commit/a02761ad6d235ccf369b86c5b4aa70fe42b53363)) +* **bench:** switch heavy benchmarks to /upload route + throttle streaming writer ([daab86a](https://github.com/Leberkas-org/TurboHTTP/commit/daab86a41a5cb62d4f810e4424546c9657dfdef8)) +* **body:** H10 truncated body error propagation, H11 chunked boundary deadlock ([561ff28](https://github.com/Leberkas-org/TurboHTTP/commit/561ff286be80a54637977d7419483f70967fc83e)) +* **body:** QueuedBodyReader.ReadAsync now respects CancellationToken ([fb5c55a](https://github.com/Leberkas-org/TurboHTTP/commit/fb5c55a48e2d1583f4d3328ee48b010fa100c740)) +* **body:** resolve pending ReadAsync on Reset to prevent InvalidOperationException ([bc21107](https://github.com/Leberkas-org/TurboHTTP/commit/bc21107a5c2be2e00cd293dbba99faf10eb7616b)) +* **ci:** Disable parallel test modules ([a1d783f](https://github.com/Leberkas-org/TurboHTTP/commit/a1d783ff137bbef145dde2d2dbbef7245e374bf4)) +* **ci:** run two test modules in parallel ([0ed7e7f](https://github.com/Leberkas-org/TurboHTTP/commit/0ed7e7f2b11ea3e17a6ffe97108138c23f12451a)) +* client flush backpressure, QUIC pipe options, test fixes ([bf3effd](https://github.com/Leberkas-org/TurboHTTP/commit/bf3effd10f1f7b5e6d4986ebb0cf7ec49d3c4fd2)) +* **e2e:** stabilize E2E integration tests ([cbc2252](https://github.com/Leberkas-org/TurboHTTP/commit/cbc22522d97a8f4a3089acb41b9be412571be0f4)) +* **e2e:** use ctx.RequestAborted instead of TestContext CancellationToken ([e6c6f56](https://github.com/Leberkas-org/TurboHTTP/commit/e6c6f560be57920460f372dae9f5aec15bf0acc5)) +* **h10/server:** dispatch streaming request bodies before full receipt ([4ed9c42](https://github.com/Leberkas-org/TurboHTTP/commit/4ed9c424126d91b38d5045919452984dd590388d)) +* **h2/server:** partial send in DrainOutboundBuffer when flow control window < chunk ([2f57852](https://github.com/Leberkas-org/TurboHTTP/commit/2f57852b8dadfd16202ce3a724aa6635e712e671)) +* **h2:** track stream-level send window in FlowController.OnDataSent ([f31784e](https://github.com/Leberkas-org/TurboHTTP/commit/f31784ed7de48eb515b2eb838bca1495629aba34)) +* **http2/3:** correct body read pending state ([f8d2485](https://github.com/Leberkas-org/TurboHTTP/commit/f8d248538f839ed6246e8e7904017ba2e341199c)) +* **http2:** make QueuedBodyReader thread-safe and fail truncated response bodies ([ba89a9c](https://github.com/Leberkas-org/TurboHTTP/commit/ba89a9c508bc72b6ff6ce5a2754c37a932e03492)) +* **http:** fix http version comparison and null checks ([91fdab1](https://github.com/Leberkas-org/TurboHTTP/commit/91fdab1787a5793bc2c1cd7d9c2f2144c65c8ee2)) +* **http:** Improve flow control and stream draining ([6ec29cb](https://github.com/Leberkas-org/TurboHTTP/commit/6ec29cb5d5bffed046d2c021990c84e819f5cfe4)) +* **quic:** update submodule — drain pending acquires on release/establish ([1defdbb](https://github.com/Leberkas-org/TurboHTTP/commit/1defdbbfb2a732402513a4faef14d7da2fd34ea2)) +* **quic:** update submodule — server stream accept loop exception handling ([63a1319](https://github.com/Leberkas-org/TurboHTTP/commit/63a1319b38f1a45449f5a364b70fff5362d0d866)) +* **quic:** update submodule with QUIC accept loop resilience ([7cee693](https://github.com/Leberkas-org/TurboHTTP/commit/7cee6937709ab21b9068e51953a99a0dcbb040a5)) +* Remove unused OpenTelemetry package ([93a9f26](https://github.com/Leberkas-org/TurboHTTP/commit/93a9f26af3f75dafbcb194d2d93e8eebdb2f51ef)) +* **server:** always call TryPullResponse from OnNetworkPull ([e18b2e3](https://github.com/Leberkas-org/TurboHTTP/commit/e18b2e312793d36be962e85ebdccbe3a6237ed18)) +* **server:** split buffered body into MAX_FRAME_SIZE-compliant DATA frames ([a64f68b](https://github.com/Leberkas-org/TurboHTTP/commit/a64f68bf788f59b89457bf4399fc057d7802f5f6)) +* **tcp:** update submodule with concurrent PipeWriter access fix ([ef32fea](https://github.com/Leberkas-org/TurboHTTP/commit/ef32fea181f593dfd1f901ef352b5fba5e0e9e59)) +* **test:** Disable parallel test collections ([0dae2a3](https://github.com/Leberkas-org/TurboHTTP/commit/0dae2a3e7837e005a2384c6fe19bca53a90362fa)) +* **test:** make integration test infrastructure parallel-safe ([9dce6a6](https://github.com/Leberkas-org/TurboHTTP/commit/9dce6a6be975cb850b4e056194757785f0208d3d)) +* **test:** raise client timeout in LargePayloadSpec for CI contention ([65dd73f](https://github.com/Leberkas-org/TurboHTTP/commit/65dd73f853c9a23cde702cf545a3a0b6698331c7)) + + +### Performance + +* **client:** remove .Async() boundary from EndpointDispatchStage ([f4a1bb4](https://github.com/Leberkas-org/TurboHTTP/commit/f4a1bb4d9e2e5792b4dee441784fe442fa61daa4)) +* **h3:** cache QPACK encode buffer across Encode() calls ([1ff7130](https://github.com/Leberkas-org/TurboHTTP/commit/1ff7130818e824abe8230e56e1ccbc20c04afacb)) +* **h3:** pool FrameDecoder and rent StreamState from pool in server ([49b3032](https://github.com/Leberkas-org/TurboHTTP/commit/49b303219df20dee3155ab3880a1b183f8b96b1e)) +* **h3:** reduce QUIC pipe MinimumSegmentSize from 16KB to 4KB ([35e45fa](https://github.com/Leberkas-org/TurboHTTP/commit/35e45fa134839650083576e10c36a8a78ca892fb)) +* pass sizeHint to GetMemory() + sync body read bypass for H10 server ([4b65fb3](https://github.com/Leberkas-org/TurboHTTP/commit/4b65fb3c864eaae98885b49d709ee33d7198400a)) +* pool TransportData wrappers + convert PipeTo messages to readonly record structs ([f27b895](https://github.com/Leberkas-org/TurboHTTP/commit/f27b895b00839e3a8064f6fe9b9b803491540f82)) +* reduce QueuedBodyReader default capacity from 64 to 8 ([1988ddb](https://github.com/Leberkas-org/TurboHTTP/commit/1988ddb89b1b7f7ec583a0d404664bd21832d501)) +* right-size body drain buffers using content-length ([5e54fe3](https://github.com/Leberkas-org/TurboHTTP/commit/5e54fe3c6ff1d648790cba7e221c97e999820049)) +* **server:** buffered body fast path for all protocol SMs ([8823dec](https://github.com/Leberkas-org/TurboHTTP/commit/8823dec035f3174ad2fdb264ba431c6565d3cace)) +* **server:** dual-mode ResponsePipeWriter with lazy Pipe upgrade ([721c992](https://github.com/Leberkas-org/TurboHTTP/commit/721c9928ae9935cc24c321d4d11028aa0bb20e39)) +* **server:** eliminate SetOnStarting closure allocation ([0ea3214](https://github.com/Leberkas-org/TurboHTTP/commit/0ea3214607db8a258a4497ac94d29c1975c3154b)) +* **server:** fix QUIC stream leak, reduce allocations, improve H2 throughput ([03a6d9c](https://github.com/Leberkas-org/TurboHTTP/commit/03a6d9c9b8f5e0481c787c832ffe4c4a3f3df402)) +* **server:** pool ArrayBufferWriter<byte> for response body buffering ([3db8272](https://github.com/Leberkas-org/TurboHTTP/commit/3db8272f2b3192579edfa53faacbe3aef979022e)) +* **server:** recycle FeatureCollection after response body consumption ([6a06a89](https://github.com/Leberkas-org/TurboHTTP/commit/6a06a89e6fec7f3a527cf0cac3c3c54edd50b69b)) +* **server:** synchronous body read bypass for pre-buffered responses ([ea52a12](https://github.com/Leberkas-org/TurboHTTP/commit/ea52a129638688772fe0991a5ea8c0fed1021c8c)) +* **tcp:** update submodule with server transport alignment ([a16fd7f](https://github.com/Leberkas-org/TurboHTTP/commit/a16fd7f777737c1561ac1f676297e67290efc1ed)) +* **tcp:** update submodule with write coalescing ([e1b512f](https://github.com/Leberkas-org/TurboHTTP/commit/e1b512fe79d069c97e70014c73dabfa35cac5adc)) + + +### Documentation + +* **notes:** document H2 response truncation race with repro steps ([0db412f](https://github.com/Leberkas-org/TurboHTTP/commit/0db412f39a1003004cda8f642eb6e196bed7d1c1)) + + +### Refactoring + +* **bench:** remove Binkraken benchmarks entirely ([fe255b2](https://github.com/Leberkas-org/TurboHTTP/commit/fe255b2c2a7f71998e66a4efa3cb6632124555ff)) +* Remove unused MemoryPool reference ([e56b38d](https://github.com/Leberkas-org/TurboHTTP/commit/e56b38df69493f202352508a3bbe7ecf3bb0acc5)) + +## [3.0.0-alpha.1](https://github.com/Leberkas-org/TurboHTTP/compare/v3.0.0-alpha...v3.0.0-alpha.1) (2026-06-02) + + +### Features + +* **client:** Add WithFirstPartyContext and WithTimeout ([4debf0f](https://github.com/Leberkas-org/TurboHTTP/commit/4debf0f06f34348036f7e0c00a1e30ae2ab41002)) +* Consolidate timer names with constants ([2c5623c](https://github.com/Leberkas-org/TurboHTTP/commit/2c5623c2fba2f94f61f775bbac094e1e8e226073)) +* **h3:** connection-error teardown on the server (stop swallow, close, RST) ([32ec3f9](https://github.com/Leberkas-org/TurboHTTP/commit/32ec3f956e084b89edf19db5443de0a5bbb8a911)) +* **http2:** adaptive receive-window growth in FlowController (client-gated) ([028c49e](https://github.com/Leberkas-org/TurboHTTP/commit/028c49e9f2fa41e7e8038f0de6aab93a474cfd81)) +* **http2:** adaptive window-scaling client options + projection ([8537293](https://github.com/Leberkas-org/TurboHTTP/commit/853729352f64d4fcd4326bdd2e655a0ea3f99f81)) +* **http2:** add RttEstimator for PING-based min-RTT measurement ([0995bb1](https://github.com/Leberkas-org/TurboHTTP/commit/0995bb1f51d0678593919a3e3786bd123152e244)) +* **http2:** add WindowScaler BDP growth formula ([6a6413d](https://github.com/Leberkas-org/TurboHTTP/commit/6a6413d0621954eec51220bd51f7878fc19d7dbe)) +* **http2:** enable adaptive window scaling ([fd722ad](https://github.com/Leberkas-org/TurboHTTP/commit/fd722ad04fa915a85086f6d276f7a772e9c4dfc4)) +* **http2:** Improve HTTP/2 protocol robustness and RFC compliance ([b67bc5d](https://github.com/Leberkas-org/TurboHTTP/commit/b67bc5d6fc63b708986caa04d94d715b226d89be)) +* **http2:** Improve interim response and trailer handling ([a64314c](https://github.com/Leberkas-org/TurboHTTP/commit/a64314cb333722b00ce7a66ba3d1a538c46781f8)) +* **http2:** project client http2 options to encoder ([2762854](https://github.com/Leberkas-org/TurboHTTP/commit/2762854d3bcb75bfb98caf37312f89fa90c895b3)) +* **http2:** raise per-stream receive window to 1 MB + E2E flow control tests ([ce844b5](https://github.com/Leberkas-org/TurboHTTP/commit/ce844b5c1b674dc093e6250ee9b93dc0a5a8780d)) +* **http2:** validate client stream IDs per RFC 9113 §5.1.1 ([0ceaad9](https://github.com/Leberkas-org/TurboHTTP/commit/0ceaad9e18d3cd8207e6e9bc212b9d70cacad4b9)) +* **http2:** wire client adaptive window scaling + RTT probes ([5cf1549](https://github.com/Leberkas-org/TurboHTTP/commit/5cf1549e709a04f337dd036f18b32c7dd16eae85)) +* **http3:** improve session manager logic ([d4eb2ac](https://github.com/Leberkas-org/TurboHTTP/commit/d4eb2ac7099d9255f9784a2e5abc33cba8846288)) +* **http3:** process inbound SETTINGS and reject duplicates ([4e73f7c](https://github.com/Leberkas-org/TurboHTTP/commit/4e73f7ce5d885d4cd34b742515a7f86afa85bd96)) +* **options:** Rename body size properties ([9467b52](https://github.com/Leberkas-org/TurboHTTP/commit/9467b527f405e81088a37c2307f4fe2d4c04590a)) +* **options:** Rename maxEndpointSubstreams to maxConcurrentEndpoints ([24b8c5e](https://github.com/Leberkas-org/TurboHTTP/commit/24b8c5ef4d8ecbbdaf42f89602a318736e2d89e6)) +* **security:** extend CVE-class protections to HTTP/3 + close HPACK ([322a53b](https://github.com/Leberkas-org/TurboHTTP/commit/322a53b5847f0477d664f38ba7658c02bb00d28e)) +* **server:** add actor-based FairShareCoordinator ([dc3d6c6](https://github.com/Leberkas-org/TurboHTTP/commit/dc3d6c68b430fff016fa5bf0b92820bd5096c761)) +* **server:** add ConnectionActor for per-connection lifecycle ([9ea7cb2](https://github.com/Leberkas-org/TurboHTTP/commit/9ea7cb26b8666e945a8648650dbe889283022d92)) +* **server:** add generic DynamicHub keyed fan-out stage ([fb9bb12](https://github.com/Leberkas-org/TurboHTTP/commit/fb9bb121188421c2f33f9668502dc679be4dcfea)) +* **server:** extract W3C trace context from inbound requests ([1c0124b](https://github.com/Leberkas-org/TurboHTTP/commit/1c0124baa3a8661acc4da5c64bd7ffc66067df90)) +* **server:** introduce ServerPipeline owning shared + per-connection flow ([cb81c9c](https://github.com/Leberkas-org/TurboHTTP/commit/cb81c9ce3b073f8a599c9bcde7322d37106dfd4f)) +* **server:** validate options on startup ([e532447](https://github.com/Leberkas-org/TurboHTTP/commit/e532447f3544b652efb277c28ff7db0c539e7f84)) +* **streams:** migrate DynamicHub tests and impl ([21c3c4b](https://github.com/Leberkas-org/TurboHTTP/commit/21c3c4b6f156c1c5e80cffb00be8cfe9ba3e79aa)) + + +### Bug Fixes + +* **client:** propagate handler exceptions, wire per-request timeout, enforce SameSite ([3bd9ddd](https://github.com/Leberkas-org/TurboHTTP/commit/3bd9ddd1609cfd86a50273ebb126800b13717763)) +* **client:** resolve typed clients via ActivatorUtilities instead of cast ([b815e42](https://github.com/Leberkas-org/TurboHTTP/commit/b815e4226ce8da8f4787db4c8eac2660fc1bc8d5)) +* **http2:** reject empty :path pseudo-header for non-CONNECT requests ([56876e3](https://github.com/Leberkas-org/TurboHTTP/commit/56876e35294738ef58204ff2bd3888ab974cb363)) +* **server:** close idle H2/H3 connections on keep-alive timeout ([86fae26](https://github.com/Leberkas-org/TurboHTTP/commit/86fae2685b3e48593720b06be27323f4b72db61c)) +* **server:** pull next pipelined response after an outbound body completes ([a78c352](https://github.com/Leberkas-org/TurboHTTP/commit/a78c352ca5738bf39010c5481c678d45981c0e59)) + + +### Documentation + +* update config docs ([b7b751f](https://github.com/Leberkas-org/TurboHTTP/commit/b7b751fd2a9ac102fda4e43a755b9a3d5d12bcff)) + + +### Refactoring + +* **client:** move H1.1 MaxPipelineDepth out of decoder options ([7e47256](https://github.com/Leberkas-org/TurboHTTP/commit/7e47256323f340c68253ded0e692c49005f27718)) +* **http2:** move RttEstimator ownership into FlowController ([db3e376](https://github.com/Leberkas-org/TurboHTTP/commit/db3e3761158789b7914d36919e650ef11fbf9f47)) +* **http2:** Simplify session manager constructor ([5bf8b84](https://github.com/Leberkas-org/TurboHTTP/commit/5bf8b848a26e58fba1fec45b6a675607ca5cce76)) +* rename instrumentation extensions ([28c8c07](https://github.com/Leberkas-org/TurboHTTP/commit/28c8c07f72c1470533c90ab09fce0537190d5d84)) +* replace local Servus.Akka with git submodule ([7bd8566](https://github.com/Leberkas-org/TurboHTTP/commit/7bd856673114389b81e3f70bd2932f5752ca514c)) +* **server:** FairShareAdmissionStage + ServerPipeline use actor-based coordinator ([67f875c](https://github.com/Leberkas-org/TurboHTTP/commit/67f875ce1b8caea96f375747f9f8472152b902ad)) +* **server:** migrate H1.0/H1.1 data-rate clock to TimeProvider ([8a9b5b0](https://github.com/Leberkas-org/TurboHTTP/commit/8a9b5b0a161d8d214f869f1f8823f385824a78d2)) +* **server:** move DynamicHub to shared Streams.Stages namespace ([6f97dc2](https://github.com/Leberkas-org/TurboHTTP/commit/6f97dc2d7e27619b056f19f81b311ad5098c058d)) +* **server:** rewrite ListenerActor to spawn ConnectionActor per connection ([638a946](https://github.com/Leberkas-org/TurboHTTP/commit/638a946a7112b87675bb0aebc31ce1605681ab7d)) +* **server:** wire ServerPipeline, remove ResponseDispatcherHub ([0667861](https://github.com/Leberkas-org/TurboHTTP/commit/0667861e6db11342b1b9bf8c6e91ac46e967dff3)) +* simplify constructor parameter passing ([86363a4](https://github.com/Leberkas-org/TurboHTTP/commit/86363a48a16ad26ce42ce4891f5ef880e2210fd0)) +* **transport:** inject TimeProvider into connection pool leases for deterministic eviction ([d878aa2](https://github.com/Leberkas-org/TurboHTTP/commit/d878aa23b9ed5f7f543e035f88a758b99f74f457)) + +## [3.0.0-alpha](https://github.com/Leberkas-org/TurboHTTP/compare/v2.0.0...v3.0.0-alpha) (2026-05-31) + + +### ⚠ BREAKING CHANGES + +* publish accumulated v3 work as alpha prereleases + +### Features + +* **ci:** Add release-next to CI triggers ([e1407f6](https://github.com/Leberkas-org/TurboHTTP/commit/e1407f63d52c189565009dcbbd2a1af40e7cf487)) +* publish accumulated v3 work as alpha prereleases ([e8b6e9a](https://github.com/Leberkas-org/TurboHTTP/commit/e8b6e9a205b7f23761e4681c4e5d3a05da94db1b)) +* **server:** connection-per-stage pipeline with fair-share dispatch ([c49104f](https://github.com/Leberkas-org/TurboHTTP/commit/c49104fa99950f8f50c10422f6aa97956e87f452)) +* **server:** data-rate monitoring and protocol server option resolution ([ad4d0b7](https://github.com/Leberkas-org/TurboHTTP/commit/ad4d0b74344830390b8561f6d9a2b1f6ea983907)) +* **server:** enforce four previously-unwired server options ([a9b581c](https://github.com/Leberkas-org/TurboHTTP/commit/a9b581c0e347bbfa5ffa746210daa4c34c429a78)) +* **server:** per-protocol connection options with resolved limit projections ([ea1eb2c](https://github.com/Leberkas-org/TurboHTTP/commit/ea1eb2ce30b67ddec940e3fb645f1df060a0ada4)) +* **servus:** add TransportBuffer.Wrap for zero-copy buffer handoff ([d52d0bf](https://github.com/Leberkas-org/TurboHTTP/commit/d52d0bffaff7c446a459e45e9dca4dda9627bf40)) + + +### Bug Fixes + +* **tests:** adjust maxParallelThreads to 0.5x ([611e5b3](https://github.com/Leberkas-org/TurboHTTP/commit/611e5b34bcc594ae702eed19d644491bbaa6e372)) + + +### Documentation + +* **architecture:** update engine and pipeline descriptions ([e5331e7](https://github.com/Leberkas-org/TurboHTTP/commit/e5331e7dc5f5d490db740761fed58d9c6f0da110)) +* **client:** correct namespaces, option defaults, and examples ([55cadd5](https://github.com/Leberkas-org/TurboHTTP/commit/55cadd5746e02af86add524d6f41e45596d14423)) +* **diagrams:** fix LikeC4 client pipeline order and component metadata ([3e3e6e2](https://github.com/Leberkas-org/TurboHTTP/commit/3e3e6e21c6b58202a7717b3fa080332a903bff30)) +* **server:** align option reference with code, fix stale architecture ([9043b06](https://github.com/Leberkas-org/TurboHTTP/commit/9043b06048a391ec927f5e558a1a53bbd60692ed)) +* **server:** reflect ASP.NET Core IServer architecture and new options ([7b7c233](https://github.com/Leberkas-org/TurboHTTP/commit/7b7c23347abfb51c97cb64664f9e7877dc8af9f5)) +* **site:** exclude internal docs from build, fix meta description, wire orphan pages ([1906807](https://github.com/Leberkas-org/TurboHTTP/commit/1906807ec376951456ba6045f16a730a15e42b96)) + + +### Refactoring + +* **client:** drop Validate from client option records ([ccf32c2](https://github.com/Leberkas-org/TurboHTTP/commit/ccf32c2df261ee6ea8a5afebe65f525712c5daf8)) +* **client:** flatten client protocol options and project via extensions ([b0c4e1f](https://github.com/Leberkas-org/TurboHTTP/commit/b0c4e1ff86e689d44fad72fb46a66ecc806f9461)) +* **codec:** bundle body encoder/decoder factory params into options records ([e75fce7](https://github.com/Leberkas-org/TurboHTTP/commit/e75fce7245cd210c68bb2a03b43761e83fe6ea56)) +* **codec:** project BodyDecoderOptions via ToBodyDecoderOptions extension ([d0bd68e](https://github.com/Leberkas-org/TurboHTTP/commit/d0bd68e9e43587ba0eca6606ea4d072be7161c80)) +* **protocol:** streamline body encoders/decoders and content classification ([a1a1a7e](https://github.com/Leberkas-org/TurboHTTP/commit/a1a1a7e44438ddff1f3cec43abc95b471926c96c)) +* **server:** project BodyEncoderOptions via ToBodyEncoderOptions extension ([af232d6](https://github.com/Leberkas-org/TurboHTTP/commit/af232d60b9e4a2d8a6e9a444ff4dd9371a850ce8)) +* **server:** remove unused form and header context abstractions ([22c84cc](https://github.com/Leberkas-org/TurboHTTP/commit/22c84ccc2c153537c7077a77fe92cc0aabf7e88c)) +* **servus:** convert backing fields to auto-properties across transport and IO stages ([9440aca](https://github.com/Leberkas-org/TurboHTTP/commit/9440acaee4f911b111a0ec89c8e18ef5113ec62f)) + ## [2.0.0](https://github.com/Leberkas-org/TurboHTTP/compare/v1.3.0...v2.0.0) (2026-05-28) diff --git a/CLAUDE.md b/CLAUDE.md index 2f3f5b7f5..49d3cb28e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,78 @@ Features (TurboHTTP/Features/) - Cookies/, Caching/, AltSvc/ Diagnostics (TurboHTTP/Diagnostics/) - Metrics, tracing, logging ``` +## Debugging with Senf.Tracing + +All state machines and stage logic are permanently instrumented with `Servus.Senf.Tracing`. To debug issues, activate tracing in tests — no ad-hoc `Console.Error.WriteLine` needed. + +### Trace levels (lowest → highest) + +| Level | What it covers | +|-------|---------------| +| **Trace** | Per-packet data flow: body chunks (bytes + pending count), body pause/resume, timer ticks | +| **Debug** | State transitions: timer scheduled/cancelled, request dispatched, body streaming start/complete, encoder pause/resume, stream opened/closed | +| **Info** | Connection lifecycle: keep-alive timeout, request headers timeout, reconnect, GOAWAY, connection lost/restored | +| **Warning** | Data rate violations, flow control violations, decode/encode failures | +| **Error** | Fatal failures that abort the connection | + +### Activating tracing in unit tests + +```csharp +using Servus.Diagnostics; +using static Servus.Senf; + +// In test constructor or setup — write to xUnit output +Tracing.Configure(new XunitTraceListener(output), TraceLevel.Trace); + +// Filter to specific category (Protocol, Stage, Handler, etc.) +Tracing.Configure(listener, TraceLevel.Debug, category => category == "Protocol"); +``` + +Minimal `IServusTraceListener` for xUnit: + +```csharp +sealed class XunitTraceListener(ITestOutputHelper output) : IServusTraceListener +{ + public bool IsEnabled(TraceLevel level, string category) => true; + public void Write(in TraceEvent evt) + => output.WriteLine("[{0}][{1}] {2}#{3:X4}: {4}", + evt.Level, evt.Category, evt.SourceType, evt.SourceHash, evt.FormatMessage()); +} +``` + +### Activating in integration tests / hosting + +```csharp +// Via DI — bridges to Microsoft.Extensions.Logging +services.AddTurboLoggerTracing(TraceLevel.Trace); + +// Or with a custom listener +services.AddTurboTracing(myListener, TraceLevel.Debug); +``` + +### Instrumented categories + +| Category | Components | +|----------|-----------| +| `Protocol` | All state machines (H10/H11/H2/H3, client + server), session managers, body encoders | +| `Stage` | `HttpConnectionStageLogic` (client), `HttpConnectionServerStageLogic` (server) | +| `Handler` | `HandlerBidiStage` (client request/response pipeline) | +| `Request` | `StreamOwner`, `TracingBidiStage` (client request lifecycle) | +| `Cache` | `CacheBidiStage` | +| `Redirect` | `RedirectBidiStage` | +| `Cookie` | `CookieBidiStage` | +| `ContentEncoding` | `ContentEncodingBidiStage` | +| `Expect100` | `ExpectContinueBidiStage` | +| `Retry` | `RetryBidiStage` | +| `AltSvc` | `AltSvcBidiStage` | + +### Debugging tips + +- **Body back-pressure issues**: Set `TraceLevel.Trace` + filter `"Protocol"` — shows every body chunk, pause/resume, and remainder buffer +- **Timer bugs**: Set `TraceLevel.Debug` — shows all timer schedule/cancel/fire events with state +- **Connection drops**: Set `TraceLevel.Info` — shows connection lost/restored, GOAWAY, keep-alive timeouts +- **Never add `Console.Error.WriteLine`** for debugging — use the permanent tracing infrastructure instead + ## Obsidian Vault (`notes/`) Single source of truth for all non-code knowledge. **Use Obsidian MCP tools** (`search_notes`, `read_note`, `write_note`, `patch_note`) — never `Read`/`Write`/`Edit` on `notes/` files. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 64ae7a0d3..7e3cc2eb5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -8,8 +8,9 @@ export default defineConfig({ ], }, title: 'TurboHTTP', - description: 'High-performance HTTP client and server for .NET built on Akka.Streams — HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 (QUIC) with automatic retries, caching, cookies, connection pooling, middleware pipeline, routing, and entity gateway.', + description: 'High-performance HTTP client and server for .NET built on Akka.Streams — HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 (QUIC). The client adds automatic retries, caching, cookies, and connection pooling; the server is a drop-in ASP.NET Core IServer (a Kestrel replacement).', base: '/', + srcExclude: ['superpowers/**', '**/CLAUDE.md'], head: [ ['link', { rel: 'icon', type: 'image/png', href: '/logo/icon.png' }], ], @@ -24,7 +25,7 @@ export default defineConfig({ { text: 'Scenarios', link: '/scenarios' }, { text: 'Client', link: '/client/' }, { text: 'Server', link: '/server/' }, - { text: 'Architecture', link: '/architecture/pipeline' }, + { text: 'Architecture', link: '/architecture/' }, { text: 'API', link: '/api/' }, ], @@ -96,10 +97,19 @@ export default defineConfig({ }, ], '/architecture/': [ + { + text: 'Architecture', + items: [ + { text: 'Overview', link: '/architecture/' }, + { text: 'Layers', link: '/architecture/layers' }, + { text: 'Scenarios', link: '/architecture/scenarios' }, + ], + }, { text: 'Client Architecture', items: [ { text: 'Request Pipeline', link: '/architecture/pipeline' }, + { text: 'Handlers', link: '/architecture/handlers' }, { text: 'Protocol Engines', link: '/architecture/engines' }, ], }, diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 100644 index 6002c50bb..000000000 --- a/docs/CLAUDE.md +++ /dev/null @@ -1,45 +0,0 @@ -# CLAUDE.md — Documentation Site - -This file guides Claude Code when working on files inside `docs/`. - -## Audience - -Every page targets **library users** — .NET developers who use TurboHTTP in their applications and want to understand how it works. The reader is NOT a protocol implementor, RFC editor, or contributor to this library's internals. - -## Content Rules - -### No RFC References - -- Never cite RFC numbers, section numbers, or specification language (e.g. "RFC 9110 §15.4", "per RFC 9112") -- Describe **behaviour** instead: "TurboHTTP follows redirects automatically" not "implements RFC 9110 §15.4 redirect semantics" -- If a feature exists because of a spec requirement, explain the *user-visible effect*, not the spec clause - -### No 1:1 Implementation Mapping - -- LikeC4 diagrams show **conceptual architecture** — layers, data flow, key components -- Do not add every internal class, stage, or actor to diagrams; keep the current abstraction level -- When adding new components to diagrams, ask: "Does a user need to know this exists?" — if not, leave it out - -### Tone and Style - -- Practical, example-driven — lead with code snippets and "what this does for you" -- Explain *what* stages and layers do, not *which spec section* they implement -- Use plain language: "keeps connections alive" not "evaluates connection persistence per §9.3" -- Tables for comparison (methods, status codes, options), callout boxes (`::: tip`, `::: warning`) for emphasis -- Keep headings scannable: H1 = page title, H2 = major sections, H3 = subsections - -### Architecture Pages - -- Stage names (e.g. `CookieBidiStage`, `RetryBidiStage`) are fine — they help users understand the pipeline -- Describe stages by **what they do for the user**: "injects cookies into outgoing requests" not "implements RFC 6265 domain matching" -- Actor and transport details are OK at the current level — don't go deeper into mailbox internals or byte-level framing - -### Guide Pages - -- Focus on: installation, configuration, usage patterns, code examples, troubleshooting -- Every feature page should answer: "How do I use this?" and "What happens automatically?" -- Warnings and tips should address practical concerns ("POST is never retried") not spec rationale - -## Build Commands - -See root `CLAUDE.md` for VitePress dev server, build, and preview commands. diff --git a/docs/api/client-options.md b/docs/api/client-options.md index df82a184b..e69b9132c 100644 --- a/docs/api/client-options.md +++ b/docs/api/client-options.md @@ -7,19 +7,19 @@ public sealed class TurboClientOptions public Uri? BaseAddress { get; set; } // Version-specific options (nested) - public Http1Options Http1 { get; init; } = new(); // HTTP/1.x settings - public Http2Options Http2 { get; init; } = new(); // HTTP/2 settings - public Http3Options Http3 { get; init; } = new(); // HTTP/3 settings + public Http1ClientOptions Http1 { get; init; } = new(); // HTTP/1.x settings + public Http2ClientOptions Http2 { get; init; } = new(); // HTTP/2 settings + public Http3ClientOptions Http3 { get; init; } = new(); // HTTP/3 settings - // Body buffering - public long MaxBufferedBodySize { get; set; } = 4 * 1024 * 1024; // 4 MiB - public long? MaxStreamedBodySize { get; set; } // unlimited + // Body buffering (response buffering threshold lives on Http1.MaxBufferedResponseBodySize) + public long? MaxStreamedResponseBodySize { get; set; } // null = unlimited; cap on a streamed response body + public int RequestBodyChunkSize { get; set; } = 16 * 1024; // 16 KB; chunk size when streaming a request body // Connection pool public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(15); public TimeSpan PooledConnectionIdleTimeout { get; set; } = TimeSpan.FromSeconds(90); public TimeSpan PooledConnectionLifetime { get; set; } = Timeout.InfiniteTimeSpan; - public uint MaxEndpointSubstreams { get; set; } = 256; + public uint MaxConcurrentEndpoints { get; set; } = 256; // TLS public bool DangerousAcceptAnyServerCertificate { get; set; } @@ -27,9 +27,11 @@ public sealed class TurboClientOptions public X509CertificateCollection? ClientCertificates { get; set; } public SslProtocols EnabledSslProtocols { get; set; } = SslProtocols.None; - // Socket options + // Socket and buffer options public int? SocketSendBufferSize { get; set; } public int? SocketReceiveBufferSize { get; set; } + public int ReceiveBufferHint { get; set; } = 64 * 1024; // 64 KB; internal receive buffer size hint + public int MinimumSegmentSize { get; set; } = 16 * 1024; // 16 KB; minimum segment size of the internal buffer pool // Proxy public bool UseProxy { get; set; } = true; @@ -50,7 +52,7 @@ public sealed class TurboClientOptions | `ConnectTimeout` | `15 s` | TCP/QUIC connection timeout | | `PooledConnectionIdleTimeout` | `90 s` | How long idle connections are kept in the pool | | `PooledConnectionLifetime` | `infinite` | Maximum lifetime of a pooled connection | -| `MaxEndpointSubstreams` | `256` | Max concurrently active endpoint substreams | +| `MaxConcurrentEndpoints` | `256` | Max concurrently active endpoints | Per-version connection limits are configured on the nested options objects: @@ -67,38 +69,53 @@ See [Connection Pooling guide](/client/connection-pooling) for pool lifecycle de ## HTTP/1.x Options ```csharp -public sealed class Http1Options +public sealed class Http1ClientOptions { + public int MaxBufferedResponseBodySize { get; set; } = 64 * 1024; // 64 KB; bodies up to this size are buffered in memory, larger are streamed public int MaxConnectionsPerServer { get; set; } = 6; public int MaxPipelineDepth { get; set; } = 16; - public int MaxResponseHeadersLength { get; set; } = 64; // KB + public int MaxResponseHeadersLength { get; set; } = 64; // KB public bool AutoHost { get; set; } = true; public bool AutoAcceptEncoding { get; set; } = true; public int MaxReconnectAttempts { get; set; } = 3; + public int MaxResponseHeaderCount { get; set; } = 100; // max number of response header fields + public int MaxResponseHeaderLineLength { get; set; } = 8 * 1024; // 8 KB; max length of a single header line + public int MaxChunkExtensionLength { get; set; } = int.MaxValue; // max total length of chunk extensions; unbounded by default } ``` | Property | Default | Description | |----------|---------|-------------| +| `MaxBufferedResponseBodySize` | `64 * 1024` (64 KB) | Response bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe | | `MaxConnectionsPerServer` | `6` | Max concurrent TCP connections per host | | `MaxPipelineDepth` | `16` | Max pipelined requests per connection | -| `MaxResponseHeadersLength` | `64` (KB) | Max response header size | +| `MaxResponseHeadersLength` | `64` (KB) | Max total response header block size | | `AutoHost` | `true` | Automatically inject `Host` header | | `AutoAcceptEncoding` | `true` | Automatically inject `Accept-Encoding` header | | `MaxReconnectAttempts` | `3` | Max reconnect attempts on connection drop | +| `MaxResponseHeaderCount` | `100` | Max number of response header fields | +| `MaxResponseHeaderLineLength` | `8 * 1024` (8 KB) | Max length of a single response header line | +| `MaxChunkExtensionLength` | `int.MaxValue` | Max total length of chunk extensions; unbounded by default | ## HTTP/2 Options ```csharp -public sealed class Http2Options +public sealed class Http2ClientOptions { public int MaxConnectionsPerServer { get; set; } = 6; public int MaxConcurrentStreams { get; set; } = 100; public int InitialConnectionWindowSize { get; set; } = 64 * 1024 * 1024; // 64 MB - public int InitialStreamWindowSize { get; set; } = 2 * 1024 * 1024; // 2 MB + public int InitialStreamWindowSize { get; set; } = 1 * 1024 * 1024; // 1 MB + public int MaxStreamWindowSize { get; set; } = 16 * 1024 * 1024; // 16 MB + public double WindowScaleThresholdMultiplier { get; set; } = 1.0; + public bool EnableAdaptiveWindowScaling { get; set; } = true; public int MaxFrameSize { get; set; } = 64 * 1024; // 64 KB public int HeaderTableSize { get; set; } = 64 * 1024; // 64 KB + public int MaxResponseHeaderListSize { get; set; } = 64 * 1024; // 64 KB; max total size of response header list + public long MaxBufferedRequestBodySize { get; set; } = 64 * 1024; // 64 KB; bodies up to this size are serialized inline, larger are streamed + public long MaxRequestBodyBufferSize { get; set; } = 64 * 1024; // 64 KB; outbound body bytes buffered per stream before the encoder pauses public int MaxReconnectAttempts { get; set; } = 3; + public int MaxReconnectBufferSize { get; set; } = 64; // max requests buffered during reconnection public TimeSpan KeepAlivePingDelay { get; set; } = Timeout.InfiniteTimeSpan; public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20); public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get; set; } = HttpKeepAlivePingPolicy.Always; @@ -110,10 +127,17 @@ public sealed class Http2Options | `MaxConnectionsPerServer` | `6` | Max concurrent TCP connections per host | | `MaxConcurrentStreams` | `100` | Max concurrent streams per connection | | `InitialConnectionWindowSize` | `64 * 1024 * 1024` (64 MB) | Connection-level flow control window | -| `InitialStreamWindowSize` | `2 * 1024 * 1024` (2 MB) | Per-stream flow control window | +| `InitialStreamWindowSize` | `1 * 1024 * 1024` (1 MB) | Initial per-stream flow control window; grows up to `MaxStreamWindowSize` under adaptive scaling | +| `MaxStreamWindowSize` | `16 * 1024 * 1024` (16 MB) | Maximum per-stream flow control window | +| `WindowScaleThresholdMultiplier` | `1.0` | RTT multiplier controlling when to scale the stream window | +| `EnableAdaptiveWindowScaling` | `true` | Grow the stream receive window based on observed throughput | | `MaxFrameSize` | `64 * 1024` (64 KB) | Max frame payload size | | `HeaderTableSize` | `64 * 1024` (64 KB) | HPACK dynamic table size | +| `MaxResponseHeaderListSize` | `64 * 1024` (64 KB) | Max total size of the response header list | +| `MaxBufferedRequestBodySize` | `64 * 1024` (64 KB) | Request bodies up to this size are serialized inline; larger bodies are streamed in chunks with backpressure | +| `MaxRequestBodyBufferSize` | `64 * 1024` (64 KB) | Max outbound body bytes buffered per stream before the body encoder pauses | | `MaxReconnectAttempts` | `3` | Max reconnect attempts on connection drop | +| `MaxReconnectBufferSize` | `64` | Max requests buffered during reconnection | | `KeepAlivePingDelay` | `infinite` | Delay before sending keep-alive PING | | `KeepAlivePingTimeout` | `20 s` | Timeout for PING acknowledgment | | `KeepAlivePingPolicy` | `Always` | When to send keep-alive PINGs | @@ -130,7 +154,7 @@ See [HTTP/2 & Multiplexing guide](/client/http2) for multiplexing configuration. ## HTTP/3 Options ```csharp -public sealed class Http3Options +public sealed class Http3ClientOptions { public int MaxConnectionsPerServer { get; set; } = 4; public int MaxConcurrentStreams { get; set; } = 100; @@ -139,7 +163,6 @@ public sealed class Http3Options public int MaxFieldSectionSize { get; set; } = 64 * 1024; // 64 KB public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(30); public int MaxReconnectAttempts { get; set; } = 3; - public bool AllowConnectionMigration { get; set; } = true; public bool EnableAltSvcDiscovery { get; set; } public int MaxReconnectBufferSize { get; set; } = 64; } @@ -154,7 +177,6 @@ public sealed class Http3Options | `MaxFieldSectionSize` | `64 * 1024` (64 KB) | Max header block size | | `IdleTimeout` | `30 s` | QUIC idle timeout | | `MaxReconnectAttempts` | `3` | Max reconnect attempts on connection drop | -| `AllowConnectionMigration` | `true` | Allow QUIC connection migration | | `EnableAltSvcDiscovery` | `false` | Auto-discover HTTP/3 via Alt-Svc headers | | `MaxReconnectBufferSize` | `64` | Max datagram buffers during reconnection | @@ -183,6 +205,8 @@ options.ClientCertificates = new X509CertificateCollection |----------|---------|-------------| | `SocketSendBufferSize` | `null` (system default) | OS socket send buffer size in bytes | | `SocketReceiveBufferSize` | `null` (system default) | OS socket receive buffer size in bytes | +| `ReceiveBufferHint` | `64 * 1024` (64 KB) | Size hint for the internal receive buffer; larger values reduce read syscalls at the cost of memory | +| `MinimumSegmentSize` | `16 * 1024` (16 KB) | Minimum segment size of the internal buffer pool | ## Proxy Options @@ -192,6 +216,12 @@ options.ClientCertificates = new X509CertificateCollection | `Proxy` | `null` | Custom proxy URI | | `DefaultProxyCredentials` | `null` | Credentials for proxy authentication | +Plain HTTP requests are relayed through the proxy directly; HTTPS requests are tunneled via `CONNECT` (with `Proxy-Authorization: Basic` when credentials are configured), and TLS is negotiated with the target through the tunnel. + +::: info HTTP/3 and proxies +QUIC cannot traverse an HTTP proxy. When a proxy applies to a request, HTTP/3 requests are downgraded to HTTP/2 (when the `VersionPolicy` is `RequestVersionOrLower`) or fail with `HttpRequestException` (`RequestVersionExact` / `RequestVersionOrHigher`). Alt-Svc HTTP/3 upgrades are also skipped for proxied hosts. Hosts matched by the proxy's bypass list are unaffected. +::: + ## Authentication Options | Property | Default | Description | @@ -203,9 +233,10 @@ options.ClientCertificates = new X509CertificateCollection | Property | Default | Description | |----------|---------|-------------| -| `MaxBufferedBodySize` | `4 * 1024 * 1024` (4 MiB) | Max response body size before buffering fails | -| `MaxStreamedBodySize` | `null` (unlimited) | Max body size for streamed (unbuffered) consumption | +| `Http1.MaxBufferedResponseBodySize` | `64 * 1024` (64 KB) | HTTP/1.x response bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe | +| `MaxStreamedResponseBodySize` | `null` (unlimited) | Cap on a streamed response body; `null` means no limit | +| `RequestBodyChunkSize` | `16 * 1024` (16 KB) | Chunk size used when streaming a request body | ::: tip -For large file downloads or uploads, use `MaxStreamedBodySize` to handle bodies larger than `MaxBufferedBodySize` without buffering the entire response in memory. +For large file downloads or uploads, consume the response as a stream. `MaxStreamedResponseBodySize` defaults to `null` — there is no built-in size cap on streamed responses. ::: diff --git a/docs/api/client.md b/docs/api/client.md index 404c17634..f6f70dcd8 100644 --- a/docs/api/client.md +++ b/docs/api/client.md @@ -85,7 +85,7 @@ See [HTTP/2 & Multiplexing guide](/client/http2) for multiplexing details. ### Timeout -Per-request timeout applied by `SendAsync`. Defaults to 60 seconds. Does not affect the channel-based API: +Per-request timeout. Defaults to 60 seconds. `SendAsync` enforces it directly; requests submitted via the channel-based API get the same timeout injected as a default when no cancellation token is set on the request: ```csharp client.Timeout = TimeSpan.FromSeconds(30); @@ -114,7 +114,7 @@ Requests are matched to responses in submission order (HTTP/1.x) or by stream ID ### CancelPendingRequests -Cancels all in-flight `SendAsync` calls and clears the pending request map. Does not affect the channel-based API: +Cancels all in-flight `SendAsync` calls, clears the pending request map, and drains (disposes) any responses already buffered in the `Responses` channel: ```csharp // Cancel everything in-flight (e.g., on application shutdown) diff --git a/docs/api/feature-options.md b/docs/api/feature-options.md index ea830e431..b75c683e5 100644 --- a/docs/api/feature-options.md +++ b/docs/api/feature-options.md @@ -36,7 +36,8 @@ See [Automatic Retries guide](/client/retries) for which methods and status code public sealed class CacheOptions { public int MaxEntries { get; set; } = 1000; - public long MaxBodyBytes { get; set; } = 52_428_800; // 50 MiB + public long MaxBodySize { get; set; } = 50 * 1024 * 1024; // 50 MiB + public long MaxTotalSize { get; set; } = 256 * 1024 * 1024; // 256 MiB public bool SharedCache { get; set; } } ``` @@ -44,7 +45,8 @@ public sealed class CacheOptions | Property | Default | Description | |----------|---------|-------------| | `MaxEntries` | `1000` | Max number of responses in the cache | -| `MaxBodyBytes` | `52_428_800` (50 MiB) | Max total size of cached response bodies | +| `MaxBodySize` | `50 * 1024 * 1024` (50 MiB) | Max body size of a single stored response; larger responses are not cached | +| `MaxTotalSize` | `256 * 1024 * 1024` (256 MiB) | Max total size of all cached response bodies combined; least-recently-used entries are evicted when exceeded | | `SharedCache` | `false` | Whether this is a shared cache (affecting `Cache-Control` directives) | ```csharp @@ -53,10 +55,10 @@ builder.Services.AddTurboHttpClient("api", ...).WithCache(); // Smaller cache for constrained environments builder.Services.AddTurboHttpClient("api", ...) - .WithCache(c => { c.MaxEntries = 100; c.MaxBodyBytes = 5 * 1024 * 1024; }); + .WithCache(c => { c.MaxEntries = 100; c.MaxBodySize = 5 * 1024 * 1024; }); // Custom store shared across clients -var sharedStore = new CacheStore(); +var sharedStore = new MyCustomCacheStore(); // implement ICacheStore builder.Services.AddTurboHttpClient("api", ...).WithCache(sharedStore); ``` @@ -103,19 +105,19 @@ See [Redirects guide](/client/redirects) for method rewriting and security detai public sealed class CompressionOptions { public string Encoding { get; set; } = "gzip"; - public long MinBodySizeBytes { get; set; } = 1024; + public long MinBodySize { get; set; } = 1024; } ``` | Property | Default | Description | |----------|---------|-------------| | `Encoding` | `"gzip"` | Compression algorithm ("gzip", "br", "deflate") | -| `MinBodySizeBytes` | `1024` | Don't compress bodies smaller than this | +| `MinBodySize` | `1024` | Don't compress bodies smaller than this | ```csharp // Request compression with Brotli for large bodies builder.Services.AddTurboHttpClient("api", ...) - .WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySizeBytes = 4096; }); + .WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySize = 4096; }); // Response decompression (automatic, no configuration needed) builder.Services.AddTurboHttpClient("api", ...).WithDecompression(enabled: true); @@ -130,18 +132,18 @@ See [Content Encoding guide](/client/content-encoding) for request compression a ```csharp public sealed class Expect100Options { - public long MinBodySizeBytes { get; set; } = 1024; + public long MinBodySize { get; set; } = 1024; } ``` | Property | Default | Description | |----------|---------|-------------| -| `MinBodySizeBytes` | `1024` | Only use Expect: 100-continue for bodies >= this size | +| `MinBodySize` | `1024` | Only use Expect: 100-continue for bodies >= this size | ```csharp // Enable 100-continue for bodies > 8 KiB builder.Services.AddTurboHttpClient("api", ...) - .WithExpectContinue(e => { e.MinBodySizeBytes = 8 * 1024; }); + .WithExpectContinue(e => { e.MinBodySize = 8 * 1024; }); ``` See [Content Encoding guide](/client/content-encoding) for Expect: 100-continue details. @@ -245,8 +247,8 @@ These types are part of the public API and can be customized: | Type | Purpose | Guide | |------|---------|-------| -| `CookieJar` | Cookie storage and injection — provided via `.WithCookies()` | [Cookies](/client/cookies) | -| `CacheStore` | In-memory LRU cache backend — provided via `.WithCache(store)` | [Caching](/client/caching) | -| `TurboHandler` | Custom request/response middleware — registered via `.AddHandler()` | [Configuration](/client/configuration) | +| `ICookieStore` | Cookie storage and injection — implement and pass to `.WithCookies(store)` | [Cookies](/client/cookies) | +| `ICacheStore` | Cache backend — implement and pass to `.WithCache(store)` | [Caching](/client/caching) | +| `TurboHandler` | Custom request/response middleware — register via `.AddHandler()` | [Configuration](/client/configuration) | See [Configuration guide](/client/configuration) for integration patterns. diff --git a/docs/api/index.md b/docs/api/index.md index 756a134be..1e73431fa 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -9,7 +9,7 @@ TurboHTTP's public API is organized into client, server, and feature configurati | `ITurboHttpClientFactory` | Creates named client instances | [Client API](./client) | | `ITurboHttpClient` | The HTTP client — `SendAsync` and channel-based API | [Client API](./client) | | `TurboClientOptions` | Connection, TLS, proxy, and protocol settings | [Client Options](./client-options) | -| `Http1Options` / `Http2Options` / `Http3Options` | Per-protocol tuning | [Client Options](./client-options) | +| `Http1ClientOptions` / `Http2ClientOptions` / `Http3ClientOptions` | Per-protocol tuning | [Client Options](./client-options) | | `RetryOptions` / `CacheOptions` / `RedirectOptions` | Feature configuration | [Feature Options](./feature-options) | | Builder extensions (`.WithRetry()`, `.WithCache()`, etc.) | Fluent feature composition | [Feature Options](./feature-options) | diff --git a/docs/api/server.md b/docs/api/server.md index 42ed24194..c2219f067 100644 --- a/docs/api/server.md +++ b/docs/api/server.md @@ -60,9 +60,10 @@ public sealed class TurboServerOptions TimeSpan HandlerTimeout { get; set; } // default: 30s TimeSpan HandlerGracePeriod { get; set; } // default: 5s - int BodyBufferThreshold { get; set; } // default: 64 * 1024 TimeSpan BodyConsumptionTimeout { get; set; } // default: 30s int ResponseBodyChunkSize { get; set; } // default: 16 * 1024 + int MaxOutboundCoalesceCount { get; set; } // default: 32 (frames merged up to factor × 16 KiB per transport write) + bool AllowResponseHeaderCompression { get; set; } // default: true (disable to mitigate CRIME/BREACH-style attacks) Http1ServerOptions Http1 { get; } Http2ServerOptions Http2 { get; } @@ -76,11 +77,15 @@ public sealed class TurboServerOptions void ListenLocalhost(ushort port, Action configure); void ListenAnyIP(ushort port); void ListenAnyIP(ushort port, Action configure); + void BindTcp(string host, ushort port); void Bind(TcpListenerOptions options); void Bind(QuicListenerOptions options); void Bind(ListenerOptions options, IListenerFactory factory); void ConfigureHttpsDefaults(Action configure); void ConfigureEndpointDefaults(Action configure); + + IList Endpoints { get; } // read-only, populated by Bind() overloads only + IList Urls { get; } // read-only, resolved to bindings at startup (add strings manually or via hosting configuration) } ``` @@ -92,15 +97,17 @@ public sealed class TurboServerOptions public sealed class TurboServerLimits { int MaxConcurrentConnections { get; set; } // default: 0 (unlimited) - int MaxConcurrentUpgradedConnections { get; set; } // default: 0 (unlimited) - long MaxRequestBodySize { get; set; } // default: 30 * 1024 * 1024 + long MaxRequestBodySize { get; set; } // default: 30,000,000 (~28.6 MiB, matching Kestrel) int MaxRequestHeaderCount { get; set; } // default: 100 int MaxRequestHeadersTotalSize { get; set; } // default: 32 * 1024 + long MaxResponseBufferSize { get; set; } // default: 64 * 1024 (per-stream response write buffer) + long? MaxRequestBufferSize { get; set; } // default: 1 MiB (transport input buffer before backpressure; null = unlimited) + int MaxResetStreamsPerWindow { get; set; } // default: 200 (HTTP/2 Rapid Reset / CVE-2023-44487 mitigation; 0 = disabled) TimeSpan KeepAliveTimeout { get; set; } // default: 130s TimeSpan RequestHeadersTimeout { get; set; } // default: 30s - double MinRequestBodyDataRate { get; set; } // default: 0 + double MinRequestBodyDataRate { get; set; } // default: 240 TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } // default: 5s - double MinResponseDataRate { get; set; } // default: 0 + double MinResponseDataRate { get; set; } // default: 240 TimeSpan MinResponseDataRateGracePeriod { get; set; } // default: 5s } ``` @@ -115,6 +122,7 @@ public sealed class TurboListenOptions(IPAddress address, ushort port) IPAddress Address { get; } ushort Port { get; } HttpProtocols Protocols { get; set; } // default: Http1AndHttp2 + TransportBufferOptions? Transport { get; set; } // default: null (protocol-optimized defaults) void UseHttps(); void UseHttps(X509Certificate2 certificate); @@ -129,6 +137,45 @@ public sealed class TurboListenOptions(IPAddress address, ushort port) --- +## Transport Buffer Options + +Controls backpressure thresholds on the read/write pipes between the OS socket and the HTTP pipeline. Applied per-connection for TCP and per-stream for QUIC. Set via `TurboListenOptions.Transport`. Every property is nullable — properties left at `null` fall back to the protocol-optimized default individually, so you only need to set the thresholds you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`. + +```csharp +public sealed class TransportBufferOptions +{ + long? InputPauseThreshold { get; set; } // bytes buffered on the read pipe before the OS socket is paused + long? InputResumeThreshold { get; set; } // buffered byte count at which reading resumes (must be <= pause threshold) + long? OutputPauseThreshold { get; set; } // bytes buffered on the write pipe before the HTTP pipeline is paused + long? OutputResumeThreshold { get; set; } // must be <= OutputPauseThreshold + int? MinimumSegmentSize { get; set; } // minimum pipe buffer segment size +} +``` + +Protocol-specific defaults applied for `null` properties (and when `Transport` itself is `null`): + +| Property | TCP (one pipe per connection) | QUIC (one pipe per stream) | +|----------|------------------------------|----------------------------| +| `InputPauseThreshold` | 1 MiB | 64 KiB | +| `InputResumeThreshold` | 512 KiB | 32 KiB | +| `OutputPauseThreshold` | 64 KiB | 64 KiB | +| `OutputResumeThreshold` | 32 KiB | 32 KiB | +| `MinimumSegmentSize` | 16 KiB | 4 KiB | + +```csharp +options.Listen(IPAddress.Any, 8080, listen => +{ + // Only the input thresholds are overridden; everything else keeps the TCP defaults + listen.Transport = new TransportBufferOptions + { + InputPauseThreshold = 2 * 1024 * 1024, + InputResumeThreshold = 1024 * 1024 + }; +}); +``` + +--- + ## HTTPS Options ```csharp @@ -168,15 +215,20 @@ public enum HttpProtocols ```csharp public sealed class Http1ServerOptions { - int MaxRequestLineLength { get; set; } // default: 8192 - int MaxRequestTargetLength { get; set; } // default: 8192 + int MaxRequestLineLength { get; set; } // default: 8 * 1024 + int MaxRequestTargetLength { get; set; } // default: 8 * 1024 int MaxPipelinedRequests { get; set; } // default: 16 - int MaxChunkExtensionLength { get; set; } // default: 4096 + int MaxChunkExtensionLength { get; set; } // default: 4 * 1024 + int MaxBufferedRequestBodySize { get; set; } // default: 64 * 1024 (bodies up to this size buffered in memory, larger streamed) TimeSpan BodyReadTimeout { get; set; } // default: 30s - long MaxRequestBodySize { get; set; } // default: 30_000_000 - int MaxHeaderListSize { get; set; } // default: 32 * 1024 - TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses global) - TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses global) + int? MaxHeaderListSize { get; set; } // default: null (uses Limits.MaxRequestHeadersTotalSize) + long? MaxRequestBodySize { get; set; } // default: null (uses Limits) + TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses Limits) + TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses Limits) + double? MinRequestBodyDataRate { get; set; } // default: null (uses Limits) + TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } // default: null (uses Limits) + double? MinResponseDataRate { get; set; } // default: null (uses Limits) + TimeSpan? MinResponseDataRateGracePeriod { get; set; } // default: null (uses Limits) } ``` @@ -190,15 +242,22 @@ public sealed class Http2ServerOptions int MaxConcurrentStreams { get; set; } // default: 100 int InitialConnectionWindowSize { get; set; } // default: 1 * 1024 * 1024 int InitialStreamWindowSize { get; set; } // default: 768 * 1024 + int MaxStreamWindowSize { get; set; } // default: 8 * 1024 * 1024 (adaptive scaling upper bound) + double WindowScaleThresholdMultiplier { get; set; } // default: 1.0 + bool EnableAdaptiveWindowScaling { get; set; } // default: true (BDP-based receive-window growth) int MaxFrameSize { get; set; } // default: 16 * 1024 - int MaxHeaderListSize { get; set; } // default: 32 * 1024 int HeaderTableSize { get; set; } // default: 4 * 1024 - long MaxRequestBodySize { get; set; } // default: 30_000_000 - long MaxResponseBufferSize { get; set; } // default: 64 * 1024 - TimeSpan KeepAliveTimeout { get; set; } // default: 130s - TimeSpan RequestHeadersTimeout { get; set; } // default: 30s - int MinRequestBodyDataRate { get; set; } // default: 240 - TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } // default: 5s + int? MaxHeaderListSize { get; set; } // default: null (uses Limits.MaxRequestHeadersTotalSize) + long? MaxResponseBufferSize { get; set; } // default: null (uses Limits.MaxResponseBufferSize) + TimeSpan KeepAlivePingDelay { get; set; } // default: infinite (server-initiated keep-alive PINGs disabled) + TimeSpan KeepAlivePingTimeout { get; set; } // default: 20s (max wait for PING ACK before closing) + long? MaxRequestBodySize { get; set; } // default: null (uses Limits) + TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses Limits) + TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses Limits) + double? MinRequestBodyDataRate { get; set; } // default: null (uses Limits) + TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } // default: null (uses Limits) + double? MinResponseDataRate { get; set; } // default: null (uses Limits) + TimeSpan? MinResponseDataRateGracePeriod { get; set; } // default: null (uses Limits) } ``` @@ -210,13 +269,16 @@ public sealed class Http2ServerOptions public sealed class Http3ServerOptions { int MaxConcurrentStreams { get; set; } // default: 100 - int MaxHeaderListSize { get; set; } // default: 32 * 1024 - int QpackMaxTableCapacity { get; set; } // default: 0 - bool EnableWebTransport { get; set; } // default: false - long MaxRequestBodySize { get; set; } // default: 30_000_000 - TimeSpan KeepAliveTimeout { get; set; } // default: 130s - TimeSpan RequestHeadersTimeout { get; set; } // default: 30s - int MinRequestBodyDataRate { get; set; } // default: 240 - TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } // default: 5s + int? MaxHeaderListSize { get; set; } // default: null (uses Limits.MaxRequestHeadersTotalSize) + int QpackMaxTableCapacity { get; set; } // default: 0 + int QpackBlockedStreams { get; set; } // default: 100 + long? MaxResponseBufferSize { get; set; } // default: null (uses Limits.MaxResponseBufferSize) + long? MaxRequestBodySize { get; set; } // default: null (uses Limits) + TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses Limits) + TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses Limits) + double? MinRequestBodyDataRate { get; set; } // default: null (uses Limits) + TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } // default: null (uses Limits) + double? MinResponseDataRate { get; set; } // default: null (uses Limits) + TimeSpan? MinResponseDataRateGracePeriod { get; set; } // default: null (uses Limits) } ``` diff --git a/docs/architecture/engines.md b/docs/architecture/engines.md index 9991938b8..f3516be1f 100644 --- a/docs/architecture/engines.md +++ b/docs/architecture/engines.md @@ -21,7 +21,7 @@ TCP → [TcpConnectionStage] → Http10ClientConnectionStage → HttpResponseMes | Component | Role | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Http10ClientConnectionStage` | Unified stage: serialises request to wire bytes (sets `Connection: close`), parses the HTTP/1.0 response, and correlates request/response (FIFO, depth 1) | +| `Http10ClientConnectionStage` | Unified stage: serialises request to wire bytes, parses the HTTP/1.0 response, and correlates request/response (FIFO, depth 1) | | `TcpConnectionStage` | TCP transport (from Servus.Akka) — acquires a connection lease from the manager actor, reads/writes bytes | **Notable behaviours:** @@ -82,8 +82,8 @@ TCP → [TcpConnectionStage] → Http20ClientConnectionStage → HttpResponseMes | Component | Role | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Http20ClientConnectionStage` | Central unified stage: allocates client stream IDs (1, 3, 5, …), HPACK-encodes request headers and emits `HEADERS` + `DATA` frames, handles frame encoding/decoding (9-byte frame header + payload), manages connection-level frames (`SETTINGS`, `PING`, `WINDOW_UPDATE`, `GOAWAY`), tracks connection and stream-level flow control windows, assembles per-stream `HEADERS` + `DATA` frames into `HttpResponseMessage`, and correlates responses by stream ID | -| `TcpConnectionStage` | TCP transport (from Servus.Akka) — emits the HTTP/2 connection preface on first connect | +| `Http20ClientConnectionStage` | Central unified stage: emits the HTTP/2 connection preface (magic + SETTINGS [+ WINDOW_UPDATE]) on first connect via `Http2ClientSessionManager`, allocates client stream IDs (1, 3, 5, …), HPACK-encodes request headers and emits `HEADERS` + `DATA` frames, handles frame encoding/decoding (9-byte frame header + payload), manages connection-level frames (`SETTINGS`, `PING`, `WINDOW_UPDATE`, `GOAWAY`), tracks connection and stream-level flow control windows, assembles per-stream `HEADERS` + `DATA` frames into `HttpResponseMessage`, and correlates responses by stream ID | +| `TcpConnectionStage` | TCP transport (from Servus.Akka) — reads and writes raw bytes over the TCP connection | **HPACK header compression:** @@ -136,7 +136,7 @@ When a connection arrives at TurboHTTP Server, the server mirrors the client arc | `Http20ServerEngine` | HTTP/2 | Stream multiplexing over a single connection; uses HPACK header compression; flow-control windows at connection and stream level | | `Http30ServerEngine` | HTTP/3 | QUIC-based multiplexing with per-stream flow control; uses QPACK header compression; eliminates head-of-line blocking | -Each server engine implements `IServerProtocolEngine`. When a connection arrives, the `NegotiatingServerEngine` delegates to `ProtocolRouter` to detect the protocol from ALPN negotiation (TLS) or the initial bytes (HTTP/1.x format, HTTP/2 preface `PRI * HTTP/2.0`, or QUIC Initial packet) and routes the connection to the appropriate version-specific engine for the duration of that connection. +Each server engine implements `IServerProtocolEngine`. When a connection arrives, the `NegotiatingServerEngine` uses a `ProtocolNegotiatingStateMachine` to detect the protocol from ALPN negotiation (TLS) or the initial bytes (HTTP/1.x request line, or the HTTP/2 preface `PRI * HTTP/2.0`), then instantiates the matching version-specific server state machine for the duration of that connection. HTTP/3 connections arrive over QUIC and are routed directly to the HTTP/3 server engine at the listener, so they never pass through this byte-sniffing step. ## Related Guides diff --git a/docs/architecture/handlers.md b/docs/architecture/handlers.md index 5d029bf48..37c8330eb 100644 --- a/docs/architecture/handlers.md +++ b/docs/architecture/handlers.md @@ -75,7 +75,7 @@ services.AddTurboHttpClient("myapi", options => { ... }) // Cookies: off by default, opt-in .WithCookies() // Shared CookieJar for this client - .WithCookies(existingJar) // Bring your own CookieJar instance + .WithCookies(existingStore) // Bring your own ICookieStore implementation // Cache: off by default, opt-in .WithCache(c => { c.MaxEntries = 1000; }) @@ -122,12 +122,12 @@ services.AddTurboHttpClient("myapi", options => { ... }) // Inline delegate for simple cases services.AddTurboHttpClient("myapi", options => { ... }) - .UseRequest(async (req, ct) => + .UseRequest((req) => { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return req; }) - .UseResponse(async (original, resp, ct) => + .UseResponse((original, resp) => { metrics.Record(original.RequestUri!, resp.StatusCode); return resp; @@ -249,9 +249,13 @@ internal sealed class TurboClientDescriptor { public RedirectPolicy? RedirectPolicy { get; set; } public RetryPolicy? RetryPolicy { get; set; } + public Expect100Policy? Expect100Policy { get; set; } + public bool AutomaticDecompression { get; set; } = true; + public CompressionPolicy? CompressionPolicy { get; set; } public bool EnableCookies { get; set; } public CookieJar? CustomCookieJar { get; set; } public CachePolicy? CachePolicy { get; set; } + public ICacheStore? CustomCacheStore { get; set; } // Type-based handlers (AddHandler) — for DI lookup by type public List HandlerTypes { get; } = []; @@ -271,16 +275,26 @@ A snapshot of the fully resolved configuration — cookie jar instance, cache st internal sealed record PipelineDescriptor( RedirectPolicy? RedirectPolicy, RetryPolicy? RetryPolicy, + Expect100Policy? Expect100Policy, + CompressionPolicy? CompressionPolicy, CookieJar? CookieJar, - HttpCacheStore? CacheStore, - IReadOnlyList Handlers) + Cache? CacheStore, + CachePolicy? CachePolicy, + IReadOnlyList Handlers, + bool AutomaticDecompression = true, + AltSvcCache? AltSvcCache = null) { public static readonly PipelineDescriptor Empty = new( RedirectPolicy: null, RetryPolicy: null, + Expect100Policy: null, + CompressionPolicy: null, CookieJar: null, CacheStore: null, - Handlers: []); + CachePolicy: null, + Handlers: [], + AutomaticDecompression: true, + AltSvcCache: null); } ``` diff --git a/docs/architecture/index.md b/docs/architecture/index.md index b4879d93c..8f742d9d3 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -72,20 +72,18 @@ Incoming TCP/QUIC Connection ↓ [Protocol Decoder] — parses HTTP/1.0, 1.1, 2, or 3 bytes ↓ -[ApplicationBridgeStage] — bridges IFeatureCollection to ASP.NET Core +[ApplicationBridgeStage] — bridges decoded HTTP to IFeatureCollection ↓ -[ASP.NET Core Pipeline] — middleware, routing, handler execution - ↓ -[Dispatcher] — DelegateDispatcher (handler) or EntityDispatcher (actor) - ↓ -[Parameter Binding] — binds route values, query, body, headers to handler parameters - ↓ -[Handler / Actor] — executes your code +[ASP.NET Core Pipeline] — middleware, routing, parameter binding, endpoint execution ↓ [Response] — writes response back through the pipeline ``` -Each connection is managed by a `ConnectionActor` that owns the full Akka.Streams graph for that connection — from transport bytes through to response serialisation. +Each connection is managed by a `ConnectionStage` that materialises a sub-graph for that connection — from transport bytes through to response serialisation. + +::: tip Routing and Dispatching +Routing, parameter binding, and request dispatching are handled by standard ASP.NET Core — middleware, endpoint routing, and model binding. If you need actor-based request handling, the optional [Servus.Akka.AspNetCore](https://github.com/Aaronontheweb/Servus.Akka.AspNetCore) package provides `EntityDispatcher` and `AkkaResults` helpers for integrating Akka actors as endpoints. +::: ## Learn More diff --git a/docs/architecture/layers.md b/docs/architecture/layers.md index 0685f3b0e..3a6332d76 100644 --- a/docs/architecture/layers.md +++ b/docs/architecture/layers.md @@ -19,7 +19,7 @@ Console.WriteLine(response.StatusCode); - Takes an `HttpRequestMessage` - Returns a `Task` - Supports `CancellationToken` for cancellation -- Respects timeouts in `TurboClientOptions` +- Respects the `Timeout` set on the client instance (`ITurboHttpClient.Timeout`) All pipeline features (cookies, caching, retries, redirects) apply automatically. You don't think about them. diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index 97d7f1e06..06d1dd83a 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -53,7 +53,7 @@ Three feedback paths create non-linear behaviour in the pipeline: ### 1. Cache Hit Short-Circuit (amber) -`CacheBidiStage` checks the in-memory `HttpCacheStore` on every request. If the stored response is still fresh, it is returned immediately. The request never reaches the `Engine` or the network. +`CacheBidiStage` checks the in-memory `MemoryCacheStore` (via `ICacheStore`) on every request. If the stored response is still fresh, it is returned immediately. The request never reaches the `Engine` or the network. If the cache entry is stale but has an `ETag` or `Last-Modified` validator, `CacheBidiStage` emits a conditional request (`If-None-Match` / `If-Modified-Since`). On a `304 Not Modified` response, `CacheBidiStage` merges the new headers into the cached entry and returns it. @@ -93,47 +93,41 @@ See [Connection Pooling Guide](../client/connection-pooling) for tuning options. ## Server Pipeline -The server pipeline mirrors the client architecture, transforming incoming bytes into responses: +The server pipeline is TurboHTTP's transport and protocol layer. It hands off request parsing to ASP.NET Core, which handles middleware, routing, and your handlers: ``` Incoming TCP/QUIC Bytes ↓ -[Transport] — accepts connection; ListenerActor spawns ConnectionActor +[Transport] — accepts connection; ListenerActor materializes a ConnectionStage ↓ -[ProtocolRouter] — detects HTTP version from initial bytes +[ProtocolRouter] — maps transport/Version to the appropriate server engine at bind time ↓ [Server Protocol Engine] — Http10/11/20/30ServerEngine decodes request, encodes response ↓ [ApplicationBridgeStage] — wraps parsed request as IFeatureCollection (HttpContext) ↓ -[Middleware] — runs registered middleware (Use/Run/Map/MapWhen) - ↓ -[Routing] — matches request path to registered route pattern - ↓ -[Dispatcher] — delegates to handler function or actor - ↓ -[Handler / Entity Actor] — executes your code; returns response +ASP.NET Core — middleware, routing, handlers, model binding ↓ [Server Protocol Engine] — encodes response to bytes ↓ Outgoing TCP/QUIC Bytes ``` -Each connection is bound to a single `ConnectionActor` that owns the entire Akka.Streams graph — from transport bytes through protocol parsing, middleware execution, routing, and response serialisation. +Each listener is backed by a single `ConnectionStage` Akka Streams graph — materialized by `ListenerActor` — that accepts and processes all incoming connections, routing transport bytes through protocol parsing up to the point where `ApplicationBridgeStage` hands control to ASP.NET Core middleware. ### Server Pipeline Stages | Stage | Role | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ProtocolRouter` | Inspects initial bytes to detect HTTP/1.0, 1.1, 2, or 3; routes to the appropriate server engine state machine | +| `ProtocolRouter` | Static factory that maps a `Version` and transport to the appropriate server engine at bind time; runtime byte-detection (when version is unspecified) is handled by `ProtocolNegotiatingStateMachine` inside the negotiating engine | | `Http*ServerEngine` | Protocol-specific state machine: parses request bytes, manages connection/stream-level flow control, encodes response frames | -| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`) | -| Middleware | Runs all registered middleware in order (outermost-first for request, innermost-first for response). Middleware can short-circuit by not calling `next(ctx)` | -| Routing | Matches the request path against registered route patterns; extracts route parameters (`{id}`, etc.) into route values | -| Dispatcher | Selects and invokes the handler: standard handler functions or actor-based routes | -| `ParameterBindingStage` | (within dispatcher) Binds route parameters, query string, body, and headers to handler parameters using reflection and model binding | +| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`); hands control to standard ASP.NET Core middleware | + +:::tip +Everything after `ApplicationBridgeStage` is standard ASP.NET Core: middleware (Use/Run/Map/MapWhen), routing, parameter binding, and your handler code. TurboHTTP owns only the transport layer and HTTP protocol parsing. +::: -After the handler returns a response, the response object flows back through the pipeline in reverse — middleware response hooks can transform or log the response, and the protocol engine serialises it back to wire bytes. +After the handler returns a response, the response object flows back to the protocol engine, which serialises it back to wire bytes. ## Related Guides diff --git a/docs/architecture/server-engines.md b/docs/architecture/server-engines.md index bb7eb185e..bd61af4c9 100644 --- a/docs/architecture/server-engines.md +++ b/docs/architecture/server-engines.md @@ -51,7 +51,7 @@ With TLS, ALPN negotiation happens during the TLS handshake. The client sends ad - Connections persist after each response (`Connection: keep-alive`) - Supports pipelining — multiple requests queued for sequential processing - Chunked transfer encoding for streaming responses -- Keep-alive timeout configurable via `TurboServerOptions.Http1.IdleTimeout` +- Keep-alive timeout configurable via `TurboServerOptions.Http1.KeepAliveTimeout` **Transport:** - `TcpListenerFactory` — TCP listener binds to configured port diff --git a/docs/architecture/server-pipeline.md b/docs/architecture/server-pipeline.md index fe05a8048..e5bc8164f 100644 --- a/docs/architecture/server-pipeline.md +++ b/docs/architecture/server-pipeline.md @@ -1,6 +1,6 @@ # Server Request Pipeline -The server request pipeline shows how an incoming request flows through the server — from raw network bytes through protocol decoding, middleware, routing, and finally to your handler or actor. +The server request pipeline shows how an incoming request flows through the server — from raw network bytes through protocol decoding to the ASP.NET Core application layer, where middleware, routing, and handlers run. @@ -10,28 +10,24 @@ The server request pipeline shows how an incoming request flows through the serv ## Request Flow -Each connection is bound to a single `ConnectionActor` that owns the entire Akka.Streams graph: +Each connection is handled by a `ConnectionStage` that owns the Akka.Streams sub-graph for that connection: ``` Incoming TCP/QUIC Connection ↓ -[Transport] — TCP or QUIC listener accepts connection +[Transport] — TCP or QUIC listener accepts connection (Servus.Akka) ↓ -[ProtocolRouter] — detects HTTP/1.0, 1.1, 2, or 3 from initial bytes +[ListenerActor] — materializes ConnectionStage per client connection ↓ -[Protocol Decoder] — Http10/11/20/30ServerEngine decodes request +[ProtocolRouter] — picks engine by transport (QUIC → Http30ServerEngine; TCP → NegotiatingServerEngine) ↓ -[ApplicationBridgeStage] — wraps parsed request as IFeatureCollection (HttpContext) +[Http*ServerEngine] — protocol-specific decoder (Http10/11/20/30ServerEngine) ↓ -[Middleware] — runs registered middleware (Use/Run/Map/MapWhen) +[ApplicationBridgeStage] — wraps parsed request as IFeatureCollection ↓ -[Routing] — matches request path to registered route pattern - ↓ -[Dispatcher] — delegates to handler function or actor - ↓ -[Parameter Binding] — binds route values, query, body, headers to handler parameters - ↓ -[Handler / Entity Actor] — executes your code +╔════════════════════════════════════════════════════════════════╗ +║ ASP.NET Core takes over (Middleware → Routing → Handlers) ║ +╚════════════════════════════════════════════════════════════════╝ ↓ [Protocol Encoder] — encodes response to wire bytes ↓ @@ -44,77 +40,54 @@ Outgoing TCP/QUIC Bytes | Stage | Role | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `Transport` (TCP/QUIC) | `ListenerActor` binds transport, accepts incoming connections, spawns `ConnectionActor` per client | -| `ProtocolRouter` | Inspects initial bytes to detect HTTP version; routes to appropriate server engine state machine | +| `Transport` (TCP/QUIC) | Accepts incoming connections over TCP or QUIC (via Servus.Akka.Transport) | +| `ListenerActor` | Binds to a port and materializes a `ConnectionStage` flow that handles each incoming connection | +| `ProtocolRouter` | Static helper used by `ServerSupervisorActor` to pick a server engine by transport: QUIC bindings get `Http30ServerEngine` directly; TCP bindings get `NegotiatingServerEngine`, which performs byte-level protocol detection | | `Http*ServerEngine` | Protocol-specific state machine: parses request bytes, manages connection/stream-level flow control, encodes response frames | -| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`) | -| Middleware | Runs all registered middleware in order (outermost-first for request, innermost-first for response). Middleware can short-circuit. | -| Routing | Matches the request path against registered route patterns; extracts route parameters (`{id}`, etc.) into route values | -| Dispatcher | Selects and invokes the handler: function-based routes or actor-based routes | -| `ParameterBindingStage` | (within dispatcher) Binds route parameters, query string, body, and headers to handler parameters using reflection and model binding | +| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`); then ASP.NET Core takes over | +| **(ASP.NET Core)** | **Middleware** (app.Use/UseMiddleware) → **Routing** (endpoint routing) → **Model Binding** → **Handler Execution** (Minimal APIs, Controllers, etc.) | --- ## Connection Lifecycle -Each connection is managed by a dedicated `ConnectionActor`: +Each connection is managed by a dedicated `ConnectionStage` graph: 1. **Bind** — `ListenerActor` binds to a TCP or QUIC port -2. **Accept** — When a client connects, `ListenerActor` spawns a new `ConnectionActor` for that connection -3. **Materialize** — `ConnectionActor` materialises the Akka.Streams graph (protocol engine → middleware → routing → dispatcher) +2. **Accept** — When a client connects, `ConnectionStage` materializes a sub-graph for that connection +3. **Materialize** — The sub-graph composes the protocol engine with `ApplicationBridgeStage` and the shared ASP.NET Core pipeline (middleware and routing) 4. **Process** — The graph processes requests and generates responses for the lifetime of the connection -5. **Cleanup** — When the client disconnects (or after idle timeout), the actor terminates and releases resources +5. **Cleanup** — When the client disconnects (or after idle timeout), the sub-graph completes and releases resources --- ## Response Flow -After the handler returns a response, the response object flows back through the pipeline in reverse: +After the handler returns a response, it flows back through the pipeline: -1. The protocol engine encodes the `TurboHttpResponse` to wire bytes -2. The transport layer (`TcpConnectionStage` or `QuicConnectionStage`) sends the bytes to the client -3. For HTTP/1.1+, the connection can remain open and reuse for the next request -4. For HTTP/1.0, the connection closes after sending the response +1. ASP.NET Core populates the `IHttpResponseFeature` (status code, headers, response body stream) +2. The protocol engine encodes the response to wire bytes using the appropriate HTTP version (1.0, 1.1, 2, or 3) +3. The transport layer (via `ConnectionStage` and Servus.Akka.Transport) sends the bytes to the client +4. For HTTP/1.1+, the connection can remain open and reuse for the next request; for HTTP/1.0, the connection closes after sending the response --- ## Protocol Detection -When a new connection arrives, `ProtocolRouter` inspects the initial bytes to determine which server engine to use: - -- **HTTP/1.x** — First line is `METHOD /path HTTP/1.x` (ASCII text) -- **HTTP/2** — First bytes are the HTTP/2 connection preface (`PRI * HTTP/2.0`) or `SETTINGS` frame -- **HTTP/3** — Connection arrives over QUIC (UDP-based transport) - -With TLS (HTTPS), ALPN negotiation happens during the TLS handshake: -- `h2` → HTTP/2 -- `h3` → HTTP/3 -- `http/1.1` or `http/1.0` → HTTP/1.1 (fallback) - -For plaintext connections, the router auto-detects from the initial bytes. +When a new connection arrives, `ServerSupervisorActor` uses `ProtocolRouter` to pick an engine based on the transport: ---- - -## Middleware Pipeline Semantics - -Middleware runs in **outermost-first order** for requests: - -```csharp -app.UseTurbo() // runs 3rd on request, 1st on response - .UseTurbo() // runs 2nd on request, 2nd on response - .UseTurbo(); // runs 1st on request, 3rd on response - // Handler/Router below -``` +- **QUIC connections** — routed directly to `Http30ServerEngine` (HTTP/3 is QUIC-only; there is no h3-over-TCP/TLS path) +- **TCP connections** — handed to `NegotiatingServerEngine`, which wraps `ProtocolNegotiatingStateMachine` to detect the protocol -Each middleware can: -- **Transform the request** — modify headers, body, or context -- **Short-circuit the chain** — return a response without calling `next(ctx)`, skipping downstream middleware and the handler -- **Transform the response** — modify status code, headers, or body -- **Observe execution** — wrap the downstream call to measure timing or log +`ProtocolNegotiatingStateMachine` selects the engine as follows: -::: tip -Unlike ASP.NET Core where middleware is registered in reverse order, TurboHTTP middleware is registered and executes in the order you call `UseTurbo()`. This is more intuitive for declarative server configuration. -::: +- **With TLS (HTTPS)** — ALPN negotiation during the TLS handshake decides the protocol: + - `h2` → HTTP/2 + - Any other negotiated protocol → HTTP/1.1 (fallback) +- **Without TLS (plaintext)** — the state machine buffers incoming bytes and sniffs: + - First 4 bytes are `PRI ` → HTTP/2 (start of the HTTP/2 connection preface) + - Request line contains `HTTP/1.0\r\n` → HTTP/1.0 + - Request line ends with `\n` → HTTP/1.1 --- diff --git a/docs/client/caching.md b/docs/client/caching.md index e81cc2ab2..f9b0d80ae 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -13,7 +13,7 @@ TurboHTTP caches **GET and HEAD responses** that the server declares as cacheabl - **Success:** `200 OK`, `203 Non-Authoritative Information`, `204 No Content` - **Permanent redirects:** `300 Multiple Choices`, `301 Moved Permanently`, `308 Permanent Redirect` - **Definitive errors:** `404 Not Found`, `405 Method Not Allowed`, `410 Gone`, `414 URI Too Long`, `501 Not Implemented` -- The response does **not** include `Cache-Control: no-store` or `Cache-Control: private` +- The response does **not** include `Cache-Control: no-store` - At least one freshness indicator is present (`max-age`, `s-maxage`, `Expires`, or a heuristic lifetime can be calculated) Responses to `POST`, `PUT`, `DELETE`, and all other methods are **never cached**. `206 Partial Content` is not cached because TurboHTTP does not reassemble partial content ranges. @@ -40,7 +40,7 @@ Once a cached response becomes stale, TurboHTTP issues a **conditional request** | `no-store` | Response | Never cache this response | | `no-cache` | Response | Cache the response, but **always revalidate** with the server before serving it | | `must-revalidate` | Response | Once stale, do not serve the cached copy without revalidation | -| `private` | Response | Do not cache — response is personalised to one user | +| `private` | Response | Only rejected by a shared cache (`SharedCache = true`); a private client cache stores the response normally | | `public` | Response | Explicitly marks the response as cacheable, even on shared caches | | `no-cache` | Request | Bypass cache; fetch a fresh response from the server | | `no-store` | Request | Bypass cache and do not store the response | @@ -122,7 +122,7 @@ builder.Services.AddTurboHttpClient("api", options => .WithCache(cache => { cache.MaxEntries = 500; // maximum number of cached responses (default: 1000) - cache.MaxBodyBytes = 512 * 1024; // maximum body size to cache, in bytes (default: 50 MiB) + cache.MaxBodySize = 512 * 1024; // maximum body size to cache, in bytes (default: 50 MiB) }); ``` @@ -154,7 +154,7 @@ request.Headers.CacheControl = new CacheControlHeaderValue By default each named client gets its own cache. To share a single store across multiple named clients — for example, to serve the same cached responses to parallel services — implement `ICacheStore` and pass the same instance to each client: ```csharp -using TurboHTTP.Protocol.Caching; +using TurboHTTP.Features.Caching; // Your thread-safe ICacheStore implementation ICacheStore sharedStore = new MySharedCacheStore(); diff --git a/docs/client/configuration.md b/docs/client/configuration.md index 04244274b..ebaf59260 100644 --- a/docs/client/configuration.md +++ b/docs/client/configuration.md @@ -76,7 +76,7 @@ options.BaseAddress = new Uri("https://api.example.com/v2/"); | `ConnectTimeout` | `TimeSpan` | `00:00:15` | Timeout for establishing a new TCP connection | | `PooledConnectionIdleTimeout` | `TimeSpan` | `00:01:30` | Time a connection may remain idle before eviction | | `PooledConnectionLifetime` | `TimeSpan` | `infinite` | Maximum lifetime of a pooled connection | -| `MaxEndpointSubstreams` | `uint` | `256` | Maximum concurrently active endpoint substreams | +| `MaxConcurrentEndpoints` | `uint` | `256` | Maximum concurrently active endpoints | ```csharp options.ConnectTimeout = TimeSpan.FromSeconds(5); @@ -84,16 +84,29 @@ options.PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2); options.PooledConnectionLifetime = TimeSpan.FromMinutes(10); ``` +### Body Buffering + +| Property | Type | Default | Description | +| ------------------------------- | ------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `Http1.MaxBufferedResponseBodySize` | `int` | `64 * 1024` (64 KB) | HTTP/1.x response bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe | +| `MaxStreamedResponseBodySize` | `long?` | `null` | Cap on a streamed response body; `null` = unlimited | +| `RequestBodyChunkSize` | `int` | `16 * 1024` (16 KB) | Chunk size used when streaming a request body to the server | + ### HTTP/1.x Options Per-version connection and protocol settings are configured on nested sub-objects: -| Property | Type | Default | Description | -| -------------------------------- | ----- | ------------- | -------------------------------------------------- | -| `Http1.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/1.x connections per host | -| `Http1.MaxPipelineDepth` | `int` | `16` | Maximum pipelined requests per HTTP/1.1 connection | -| `Http1.MaxResponseHeadersLength` | `int` | `64 * 1024` | Max response header size | -| `Http1.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop | +| Property | Type | Default | Description | +| ------------------------------------- | ------ | -------------------- | -------------------------------------------------- | +| `Http1.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/1.x connections per host | +| `Http1.MaxPipelineDepth` | `int` | `16` | Maximum pipelined requests per HTTP/1.1 connection | +| `Http1.MaxResponseHeadersLength` | `int` | `64` (KB) | Max response header size in kilobytes | +| `Http1.AutoHost` | `bool` | `true` | Automatically add the `Host` header | +| `Http1.AutoAcceptEncoding` | `bool` | `true` | Automatically add `Accept-Encoding` header | +| `Http1.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop | +| `Http1.MaxResponseHeaderCount` | `int` | `100` | Maximum number of response header fields accepted | +| `Http1.MaxResponseHeaderLineLength` | `int` | `8 * 1024` (8 KB) | Maximum length of a single response header line | +| `Http1.MaxChunkExtensionLength` | `int` | `int.MaxValue` | Maximum length of chunk extension data (unbounded by default) | ```csharp options.Http1.MaxConnectionsPerServer = 12; // raise for parallel HTTP/1.1 @@ -102,18 +115,27 @@ options.Http1.MaxPipelineDepth = 32; ### HTTP/2 Options -| Property | Type | Default | Description | -| ------------------------------- | ----- | -------------------- | ---------------------------------------------- | -| `Http2.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/2 connections per host | -| `Http2.MaxConcurrentStreams` | `int` | `100` | Maximum concurrent streams per connection | -| `Http2.MaxFrameSize` | `int` | `64 * 1024` (64 KiB) | Maximum HTTP/2 frame payload size | -| `Http2.HeaderTableSize` | `int` | `64 * 1024` (64 KiB) | HPACK dynamic table size | -| `Http2.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop | +| Property | Type | Default | Description | +| ------------------------------------------ | -------------------------- | ------------------------------ | ---------------------------------------------------------------------- | +| `Http2.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/2 connections per host | +| `Http2.MaxConcurrentStreams` | `int` | `100` | Maximum concurrent streams per connection | +| `Http2.InitialConnectionWindowSize` | `int` | `64 * 1024 * 1024` (64 MiB) | Initial flow-control window for the whole connection | +| `Http2.InitialStreamWindowSize` | `int` | `1 * 1024 * 1024` (1 MiB) | Initial flow-control window per stream (grows up to `MaxStreamWindowSize`) | +| `Http2.MaxStreamWindowSize` | `int` | `16 * 1024 * 1024` (16 MiB) | Upper bound for adaptive stream window growth | +| `Http2.WindowScaleThresholdMultiplier` | `double` | `1.0` | RTT multiplier that triggers a window-size increase | +| `Http2.EnableAdaptiveWindowScaling` | `bool` | `true` | Automatically grow receive windows based on measured RTT | +| `Http2.MaxFrameSize` | `int` | `64 * 1024` (64 KiB) | Maximum HTTP/2 frame payload size | +| `Http2.HeaderTableSize` | `int` | `64 * 1024` (64 KiB) | HPACK dynamic table size | +| `Http2.MaxResponseHeaderListSize` | `int` | `64 * 1024` (64 KiB) | Maximum total size of response header fields accepted | +| `Http2.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop | +| `Http2.KeepAlivePingDelay` | `TimeSpan` | `infinite` | Interval between keep-alive PINGs (`infinite` = disabled) | +| `Http2.KeepAlivePingTimeout` | `TimeSpan` | `00:00:20` | Time to wait for a PING ACK before closing the connection | +| `Http2.KeepAlivePingPolicy` | `HttpKeepAlivePingPolicy` | `Always` | When to send keep-alive PINGs | Increase frame size for workloads with large response bodies to reduce framing overhead: ```csharp -options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 16 KiB) +options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 64 KiB) ``` ### HTTP/3 Options @@ -121,13 +143,14 @@ options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 16 KiB) | Property | Type | Default | Description | | -------------------------------- | ---------- | -------------------- | -------------------------------------------- | | `Http3.MaxConnectionsPerServer` | `int` | `4` | Maximum concurrent QUIC connections per host | +| `Http3.MaxConcurrentStreams` | `int` | `100` | Maximum concurrent streams per connection | | `Http3.QpackMaxTableCapacity` | `int` | `16 * 1024` (16 KiB) | QPACK dynamic table size | | `Http3.QpackBlockedStreams` | `int` | `100` | Max streams blocked waiting for QPACK | | `Http3.MaxFieldSectionSize` | `int` | `64 * 1024` (64 KiB) | Max header block size | | `Http3.IdleTimeout` | `TimeSpan` | `00:00:30` | QUIC idle timeout | | `Http3.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop | -| `Http3.AllowConnectionMigration` | `bool` | `true` | Allow QUIC connection migration | | `Http3.EnableAltSvcDiscovery` | `bool` | `false` | Auto-discover HTTP/3 via Alt-Svc headers | +| `Http3.MaxReconnectBufferSize` | `int` | `64` | Number of in-flight requests buffered for replay on reconnect | See [HTTP/3 & QUIC guide](./http3) for QUIC-specific configuration and Alt-Svc discovery. @@ -209,24 +232,39 @@ See [Automatic Retries guide](./retries) for which methods and status codes trig ```csharp .WithCache() -.WithCache(c => { c.MaxEntries = 200; c.MaxBodyBytes = 5 * 1024 * 1024; }) +.WithCache(c => { c.MaxEntries = 200; c.MaxBodySize = 5 * 1024 * 1024; }) ``` -To share a single store across multiple named clients, pass a `CacheStore` directly: +To share a single store across multiple named clients, implement the `ICacheStore` interface and pass it to `WithCache()`: ```csharp -var sharedStore = new CacheStore(); +using TurboHTTP.Features.Caching; + +public sealed class MyCacheStore : ICacheStore +{ + private readonly Dictionary _entries = new(); + + public bool TryGet(string key, out CacheStoreEntry? entry) => _entries.TryGetValue(key, out entry); + public void Set(string key, CacheStoreEntry entry) => _entries[key] = entry; + public bool Remove(string key) => _entries.Remove(key); + public void Clear() => _entries.Clear(); + public void Dispose() { } +} + +var sharedStore = new MyCacheStore(); builder.Services.AddTurboHttpClient("client-a", options => { ... }).WithCache(sharedStore); builder.Services.AddTurboHttpClient("client-b", options => { ... }).WithCache(sharedStore); ``` +By default, each client gets its own in-memory cache. Pass a shared `ICacheStore` to reuse cache entries across multiple clients. + **`CacheOptions` properties:** | Property | Type | Default | Description | | -------------- | ------ | ------------------- | ------------------------------------------- | | `MaxEntries` | `int` | `1000` | Maximum entries in the LRU store | -| `MaxBodyBytes` | `long` | `52428800` (50 MiB) | Maximum body size per cached response | +| `MaxBodySize` | `long` | `50 * 1024 * 1024` (50 MiB) | Maximum body size per cached response | | `SharedCache` | `bool` | `false` | When `true`, acts as a shared (proxy) cache | See [HTTP Caching guide](./caching) for freshness evaluation and conditional request behaviour. @@ -234,8 +272,27 @@ See [HTTP Caching guide](./caching) for freshness evaluation and conditional req ### Cookie management ```csharp -.WithCookies() // private cookie jar per named client -.WithCookies(sharedJar) // shared CookieJar across multiple clients +using TurboHTTP.Features.Cookies; + +public sealed class MyCookieStore : ICookieStore +{ + private readonly List _entries = new(); + + public IReadOnlyList GetAll() => _entries.AsReadOnly(); + public void Add(CookieStoreEntry entry) => _entries.Add(entry); + public void Remove(string name, string domain, string path) + => _entries.RemoveAll(e => e.Name == name && e.Domain == domain && e.Path == path); + public void Clear() => _entries.Clear(); + public int Count => _entries.Count; +} + +// Private cookie jar per named client (default) +.WithCookies() + +// Shared cookie store across multiple clients +var sharedStore = new MyCookieStore(); +builder.Services.AddTurboHttpClient("client-a", options => { ... }).WithCookies(sharedStore); +builder.Services.AddTurboHttpClient("client-b", options => { ... }).WithCookies(sharedStore); ``` See [Cookies guide](./cookies) for session and domain handling. @@ -251,10 +308,10 @@ See [Cookies guide](./cookies) for session and domain handling. ```csharp .WithRequestCompression() // gzip bodies >= 1 KiB -.WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySizeBytes = 4096; }) +.WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySize = 4096; }) .WithExpectContinue() // Expect: 100-continue for bodies >= 1 KiB -.WithExpectContinue(e => { e.MinBodySizeBytes = 8192; }) +.WithExpectContinue(e => { e.MinBodySize = 8192; }) ``` See [Content Encoding guide](./content-encoding) for details. diff --git a/docs/client/connection-pooling.md b/docs/client/connection-pooling.md index 565a98065..5fb557f14 100644 --- a/docs/client/connection-pooling.md +++ b/docs/client/connection-pooling.md @@ -7,10 +7,11 @@ TurboHTTP automatically manages a pool of connections for each host, so you neve Each unique host (scheme + hostname + port + HTTP version) gets its own connection pool. When a request arrives, TurboHTTP tries to reuse an existing open connection. If all connections are busy and the per-host limit has not been reached, a new connection is established. If the limit is already reached, the request waits until a connection becomes free. ``` -Request → TcpConnectionManagerActor (per-host actor) - ├─ Idle connection available? → Return lease - ├─ Below per-host limit? → Establish new connection - └─ At per-host limit? → Wait for release +Request → ClientStreamManager (global router) + └─ StreamOwner (per-endpoint actor) + ├─ Idle connection available? → Return lease + ├─ Below per-host limit? → Establish new connection + └─ At per-host limit? → Wait for release ``` The pool runs entirely in the background. Your code just calls `SendAsync` — connection acquisition, reuse, and lifecycle are transparent. @@ -35,7 +36,7 @@ The idle timeout is measured from the moment a connection returns to the pool wi If a connection is dropped unexpectedly (network interruption, server-side timeout, or RST), TurboHTTP detects the failure and reconnects automatically. While reconnecting, queued requests wait for the connection to recover. Once reconnected, TurboHTTP replays the queue. ::: tip Backoff timing -Reconnect attempts use exponential backoff — each failed attempt waits progressively longer before the next try (1 s → 2 s → 4 s → 8 s → 16 s cap). +Reconnect attempts use exponential backoff — each failed attempt waits progressively longer before the next try (100 ms → 200 ms → 400 ms → 800 ms → 1.6 s → 3.2 s → 6.4 s → 12.8 s → 25.6 s → 30 s cap). ::: ## Per-Host Concurrency Limits diff --git a/docs/client/content-encoding.md b/docs/client/content-encoding.md index 4e0e5c9f4..b4c941b9a 100644 --- a/docs/client/content-encoding.md +++ b/docs/client/content-encoding.md @@ -7,7 +7,7 @@ TurboHTTP automatically decompresses compressed HTTP responses. When a server se | Encoding | Header token | Notes | | -------- | ---------------- | ------------------------------------------------------ | | Gzip | `gzip`, `x-gzip` | Most common; used by the majority of web servers | -| Deflate | `deflate` | Handles both zlib-wrapped and raw deflate formats | +| Deflate | `deflate` | zlib-wrapped deflate format (RFC 1950) | | Brotli | `br` | Best compression ratio; requires modern server support | | Identity | `identity` | No compression; body passed through unchanged | @@ -20,9 +20,9 @@ When a response arrives: 1. TurboHTTP reads the `Content-Encoding` header. 2. The body is decompressed using the appropriate algorithm. 3. The `Content-Encoding` header is removed from the response. -4. `Content-Length` is updated to reflect the decompressed size. +4. `Content-Length` is removed (the decompressed size is not known up front). -The final `HttpResponseMessage` you receive has an uncompressed body and accurate content headers. +The final `HttpResponseMessage` you receive has an uncompressed body. ```csharp var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/data")); @@ -33,11 +33,11 @@ var text = await response.Content.ReadAsStringAsync(); ## Stacked Encodings -If a response uses multiple encodings (e.g., `Content-Encoding: gzip, br`), TurboHTTP decodes them in the correct reverse order — the outermost encoding is decoded first. This matches how stacked encodings are applied by the server. +TurboHTTP does not support stacked encodings (e.g., `Content-Encoding: gzip, br`). When a response carries multiple comma-separated encoding tokens, decompression will fail silently and the response body will be empty. To avoid this, do not advertise encoding combinations in `Accept-Encoding` that the server might stack — use a single preferred encoding instead. ## Unknown Encodings -If the server sends a `Content-Encoding` value that TurboHTTP does not recognise, it throws `HttpDecoderException` rather than silently returning corrupted data. This prevents your application from processing a response body it cannot correctly interpret. +If the server sends a `Content-Encoding` value that TurboHTTP does not recognise, the response is passed through unchanged with the compressed body intact. TurboHTTP only decompresses encodings it recognises (gzip, deflate, br, identity). ## Overriding Accept-Encoding diff --git a/docs/client/cookies.md b/docs/client/cookies.md index 0d6e4067c..6721cd8e4 100644 --- a/docs/client/cookies.md +++ b/docs/client/cookies.md @@ -1,6 +1,6 @@ # Cookie Management -TurboHTTP handles cookies automatically. When a server sends a `Set-Cookie` header, TurboHTTP stores it and attaches it to subsequent requests that match the cookie's domain and path — no configuration needed. +TurboHTTP cookie handling is opt-in. Call `.WithCookies()` on the client builder to enable it. Once enabled, when a server sends a `Set-Cookie` header, TurboHTTP stores it and attaches it to subsequent requests that match the cookie's domain and path. ## How It Works @@ -13,13 +13,17 @@ Both steps happen transparently inside the request pipeline. Cookies from a logi ## Cookie Isolation -Each `TurboHttpClient` instance has its own `CookieJar`. Cookies received by one client are never shared with another. This means: +Cookies are disabled unless `.WithCookies()` is called on the builder. When enabled, each client gets its own isolated `CookieJar`. Cookies received by one client are never shared with another. This means: - A client used for API calls and a client used for authentication do **not** share cookie state. - Creating multiple clients for different services keeps their session cookies completely separate. ```csharp -// These two clients have independent cookie jars +// Enable cookies independently for each client +builder.Services.AddTurboHttpClient("api", ...).WithCookies(); +builder.Services.AddTurboHttpClient("auth", ...).WithCookies(); + +// Each client now has its own isolated cookie jar var apiClient = factory.CreateClient("api"); var authClient = factory.CreateClient("auth"); ``` @@ -69,14 +73,23 @@ Set-Cookie: session=xyz; HttpOnly ### `SameSite` -Controls whether a cookie is sent with cross-site requests. TurboHTTP stores the `SameSite` attribute but does **not** enforce it — the library always sends cookies that match domain and path rules. SameSite enforcement is a browser-level protection that does not apply to programmatic HTTP clients. +Controls whether a cookie is sent with cross-site requests. Because a programmatic HTTP client has no inherent notion of "the current site", TurboHTTP treats every request as first-party (same-site) by default and sends matching cookies. To opt into `SameSite` enforcement, tell TurboHTTP which site is initiating the request: + +```csharp +var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/transfer") + .WithFirstPartyContext(new Uri("https://app.other.com/")); +``` + +When a first-party context is set and the target is **cross-site** relative to it, TurboHTTP applies the policy below before injecting cookies: + +| Value | Cross-site behaviour | +| ---------- | --------------------------------------------------------------- | +| `Strict` | Never sent cross-site | +| `Lax` | Sent cross-site only for safe top-level navigations (GET/HEAD) | +| `None` | Always sent (requires `Secure`) | +| _(absent)_ | No restriction — sent like `None` | -| Value | Meaning | -| ---------- | -------------------------------------------------------------- | -| `Strict` | Cookie sent only for requests originating from the same site | -| `Lax` | Cookie sent for same-site and top-level cross-site navigations | -| `None` | Cookie sent with all requests (requires `Secure`) | -| _(absent)_ | No policy; treated like `Lax` in browsers | +Two requests are considered same-site when they share the same registrable domain (e.g. `app.example.com` and `api.example.com`). TurboHTTP does not bundle a public-suffix list, so multi-level suffixes like `co.uk` are compared on their last two labels. ## Expiration @@ -109,10 +122,10 @@ Set-Cookie: sid=abc123 ← no expiry: lasts until the client is disposed ## Sharing a Cookie Store -By default each named client gets its own isolated cookie store. To share cookies across multiple clients — for example, so that a login performed by one client is visible to another — implement `ICookieStore` and pass the same instance to each: +When `.WithCookies()` is called without arguments, each client gets its own isolated in-memory store. To share cookies across multiple clients — for example, so that a login performed by one client is visible to another — implement `ICookieStore` and pass the same instance to each: ```csharp -using TurboHTTP.Protocol.Cookies; +using TurboHTTP.Features.Cookies; // Your thread-safe ICookieStore implementation ICookieStore sharedStore = new MySharedCookieStore(); @@ -133,7 +146,7 @@ builder.Services.AddTurboHttpClient("api", options => A cookie set during login on the `auth` client will now be available to the `api` client. ::: warning Thread safety -When an `ICookieStore` is shared across multiple clients it will receive concurrent reads and writes. Your implementation must be thread-safe. +`ICookieStore` implementations are not required to be thread-safe when used by a single client — the request pipeline accesses the store on one logical thread at a time. However, when the **same store instance is shared across multiple clients**, those pipelines run concurrently and can access the store simultaneously. In that case your implementation must handle concurrent reads and writes safely. ::: ::: info How it works diff --git a/docs/client/http2.md b/docs/client/http2.md index 3ea6e2394..40a0c214f 100644 --- a/docs/client/http2.md +++ b/docs/client/http2.md @@ -93,12 +93,12 @@ HTTP/2 over cleartext (`http://` URLs, sometimes called h2c) is also supported. ## Frame Size -Each HTTP/2 request and response is broken into frames before being sent over the wire. The default maximum frame size is 16 KiB. Increase it for workloads that transfer large bodies to reduce framing overhead: +Each HTTP/2 request and response is broken into frames before being sent over the wire. The default maximum frame size is 64 KiB (the protocol minimum is 16 KiB). Increase it for workloads that transfer large bodies to reduce framing overhead: ```csharp builder.Services.AddTurboHttpClient("http2-api", options => { - options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 16 KiB, max: 16 MiB) + options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 64 KiB, max: 16 MiB) }); ``` diff --git a/docs/client/http3.md b/docs/client/http3.md index 8ef5c6551..91e857f32 100644 --- a/docs/client/http3.md +++ b/docs/client/http3.md @@ -52,26 +52,17 @@ builder.Services.AddTurboHttpClient("http3-api", options => ### All HTTP/3 options -| Property | Type | Default | Description | -| -------------------------- | ---------- | -------------------- | --------------------------------------------- | -| `MaxConnectionsPerServer` | `int` | `4` | Max concurrent QUIC connections per host | -| `QpackMaxTableCapacity` | `int` | `16 * 1024` (16 KiB) | QPACK dynamic table size in bytes | -| `QpackBlockedStreams` | `int` | `100` | Max streams blocked waiting for QPACK encoder | -| `MaxFieldSectionSize` | `int` | `64 * 1024` (64 KiB) | Max header block size | -| `IdleTimeout` | `TimeSpan` | `30 s` | QUIC idle timeout | -| `MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop | -| `AllowConnectionMigration` | `bool` | `true` | Allow QUIC connection migration | -| `EnableAltSvcDiscovery` | `bool` | `false` | Auto-discover HTTP/3 via Alt-Svc headers | - -## Connection Migration - -QUIC connections can survive IP address changes. When the client moves between networks (e.g., Wi-Fi to cellular), the connection continues transparently without re-establishing: - -```csharp -options.Http3.AllowConnectionMigration = true; // default: true -``` - -When disabled, TurboHTTP closes the connection on address change and reconnects via the normal reconnect mechanism. +| Property | Type | Default | Description | +| -------------------------- | ---------- | -------------------- | --------------------------------------------------------- | +| `MaxConnectionsPerServer` | `int` | `4` | Max concurrent QUIC connections per host | +| `MaxConcurrentStreams` | `int` | `100` | Max concurrent request streams per connection | +| `QpackMaxTableCapacity` | `int` | `16 * 1024` (16 KiB) | QPACK dynamic table size in bytes | +| `QpackBlockedStreams` | `int` | `100` | Max streams blocked waiting for QPACK encoder | +| `MaxFieldSectionSize` | `int` | `64 * 1024` (64 KiB) | Max header block size | +| `IdleTimeout` | `TimeSpan` | `30 s` | QUIC idle timeout | +| `MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop | +| `EnableAltSvcDiscovery` | `bool` | `false` | Auto-discover HTTP/3 via Alt-Svc headers | +| `MaxReconnectBufferSize` | `int` | `64` | Max number of requests buffered during a reconnect | ## Alt-Svc Discovery @@ -83,22 +74,22 @@ options.Http3.EnableAltSvcDiscovery = true; // default: false This is opt-in because not all environments support QUIC (firewalls may block UDP). Enable it when you know your network path supports QUIC and want automatic protocol upgrade. -## Server Push +## HTTP/3 and Forward Proxies -HTTP/3 supports server push, where the server proactively sends resources the client hasn't requested yet. This is disabled by default: +QUIC cannot traverse an HTTP forward proxy (`CONNECT` tunnels carry TCP, not UDP). When a proxy is configured and applies to a request: -```csharp -options.Http3.AllowServerPush = true; // default: false -``` +- HTTP/3 requests with `HttpVersionPolicy.RequestVersionOrLower` (the default) are transparently downgraded to HTTP/2 and tunneled via `CONNECT`. +- HTTP/3 requests with `RequestVersionExact` or `RequestVersionOrHigher` fail with `HttpRequestException`. +- Alt-Svc HTTP/3 upgrades are skipped for proxied hosts. -When disabled, any PUSH_PROMISE frames from the server are rejected. +Hosts matched by the proxy's bypass list keep using HTTP/3 directly. ## QPACK Header Compression HTTP/3 uses QPACK for header compression (the QUIC equivalent of HPACK in HTTP/2). TurboHTTP manages QPACK encoding and decoding automatically. Tune the dynamic table size if needed: ```csharp -options.Http3.QpackMaxTableCapacity = 8192; // default: 4096 +options.Http3.QpackMaxTableCapacity = 8192; // default: 16 * 1024 (16 KiB) options.Http3.QpackBlockedStreams = 200; // default: 100 ``` diff --git a/docs/client/index.md b/docs/client/index.md index 33fdcdc52..562d73ec8 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -11,7 +11,7 @@ See [Installation & Setup](./installation) for DI registration, named clients, a ::: ::: info Looking for the server? -TurboHTTP also provides a server with middleware, routing, and entity gateway. See the [Server Guide](/server/). +TurboHTTP also provides a high-performance drop-in ASP.NET Core IServer (a Kestrel replacement). See the [Server Guide](/server/). ::: ## High-Throughput Usage @@ -73,7 +73,7 @@ With HTTP/2, all 1000 requests flow over a single TCP connection as concurrent s ### Backpressure -The channel has a bounded capacity. If the connection cannot keep up with your producer, `WriteAsync` will pause automatically until there is room. You never drop requests — the channel applies backpressure instead. +The `Requests` channel is unbounded, so `WriteAsync` never pauses — requests are accepted immediately regardless of how fast the connection can process them. Backpressure is applied further down the pipeline: each endpoint's internal dispatch channel is bounded, so requests queue at that point if the connection is saturated. You never drop requests, but producers that outpace the connection will accumulate requests in memory. ## What's Included @@ -88,7 +88,7 @@ TurboHTTP works out of the box — no middleware to wire up, no Polly policies t | **Cookie Management** | `CookieJar` stores `Set-Cookie` responses and injects cookies on subsequent requests automatically | | **Content Encoding** | Automatic gzip, deflate, and Brotli decompression | | **Connection Pooling** | Per-host pools with idle eviction, automatic reconnect, and configurable concurrency limits | -| **Channel-based API** | `ChannelWriter`/`ChannelReader` interface for backpressure-aware, high-throughput request pipelines | +| **Channel-based API** | `ChannelWriter`/`ChannelReader` interface for high-throughput request pipelines; backpressure is enforced at the internal per-endpoint dispatch layer | ## Next Steps diff --git a/docs/client/installation.md b/docs/client/installation.md index 08f44836f..1d7b1bcad 100644 --- a/docs/client/installation.md +++ b/docs/client/installation.md @@ -22,7 +22,7 @@ Or add it to your `.csproj`: Register TurboHTTP in your `IServiceCollection`: ```csharp -using TurboHTTP; +using TurboHTTP.Client; var builder = WebApplication.CreateBuilder(args); @@ -95,17 +95,17 @@ public sealed class GatewayService ## Typed Clients -Bind a client directly to a service class: +Register a named client and resolve it directly as a typed `ITurboHttpClient` subtype: ```csharp -builder.Services.AddTurboHttpClient(options => +builder.Services.AddTurboHttpClient(options => { options.BaseAddress = new Uri("https://api.example.com"); }) .WithRetry(); ``` -The DI container injects `ITurboHttpClient` into `OrderService` automatically. +`TClient` must be `ITurboHttpClient` or a class that derives from it — the registration casts `factory.CreateClient(name)` to `TClient` at resolution time. Passing an arbitrary POCO service class (one that does not implement `ITurboHttpClient`) will throw `InvalidCastException` when the service is resolved. ## Fluent Builder API @@ -128,7 +128,7 @@ builder.Services.AddTurboHttpClient("full-featured", options => A complete console application using the DI-based approach: ```csharp -using TurboHTTP; +using TurboHTTP.Client; using Microsoft.Extensions.DependencyInjection; var services = new ServiceCollection(); diff --git a/docs/client/redirects.md b/docs/client/redirects.md index 34771800b..55ae4b1ba 100644 --- a/docs/client/redirects.md +++ b/docs/client/redirects.md @@ -48,7 +48,7 @@ Same-origin redirects preserve the `Authorization` header normally. ### HTTPS → HTTP Downgrade Protection -TurboHTTP blocks redirects that would downgrade from `https://` to `http://`. If a server tries to redirect you from an encrypted connection to a cleartext one, TurboHTTP throws a `RedirectException` with `RedirectError.ProtocolDowngrade` instead of following it. +TurboHTTP blocks redirects that would downgrade from `https://` to `http://`. If a server tries to redirect you from an encrypted connection to a cleartext one, TurboHTTP fails the request rather than following it. ``` Original: GET https://secure.example.com/data @@ -63,10 +63,10 @@ The `Cookie` header is never blindly forwarded across redirects. For each redire ## Loop Detection -TurboHTTP tracks every URL visited during a redirect chain. If the same URL appears twice, it throws a `RedirectException` with `RedirectError.RedirectLoop` immediately rather than continuing. This prevents infinite redirect loops caused by misconfigured servers from hanging your application. +TurboHTTP tracks every URL visited during a redirect chain. If the same URL appears twice, it fails the request immediately rather than continuing. This prevents infinite redirect loops caused by misconfigured servers from hanging your application. ``` -GET /a → 302 → /b → 302 → /a ← RedirectException (loop detected) +GET /a → 302 → /b → 302 → /a ← request fails (loop detected) ``` ## Configuration @@ -95,7 +95,7 @@ builder.Services.AddTurboHttpClient("strict", options => ### `MaxRedirects` -The maximum number of redirect hops to follow before giving up. Default: **10**. Exceeding this limit throws `RedirectException` with `RedirectError.MaxRedirectsExceeded`. +The maximum number of redirect hops to follow before giving up. Default: **10**. Exceeding this limit causes the request to fail. ### `AllowHttpsToHttpDowngrade` @@ -107,29 +107,23 @@ This is rarely needed. Only enable it in fully-trusted internal networks where y Omit `.WithRedirect()` to leave redirects disabled entirely. All 3xx responses are returned as-is. -## Handling Redirect Exceptions +## Handling Redirect Failures -When a redirect cannot be completed, TurboHTTP throws `RedirectException`. You can handle each case separately: +When a redirect cannot be completed — due to too many hops, a detected loop, or a blocked HTTPS→HTTP downgrade — the failure surfaces as an exception thrown from `SendAsync`. You catch and handle it using the standard exception handling: ```csharp try { var response = await client.SendAsync(request); } -catch (RedirectException ex) when (ex.Error == RedirectError.MaxRedirectsExceeded) +catch (Exception ex) { - Console.WriteLine($"Too many redirects: {ex.Message}"); -} -catch (RedirectException ex) when (ex.Error == RedirectError.RedirectLoop) -{ - Console.WriteLine($"Redirect loop detected: {ex.Message}"); -} -catch (RedirectException ex) when (ex.Error == RedirectError.ProtocolDowngrade) -{ - Console.WriteLine($"Blocked HTTPS→HTTP downgrade: {ex.Message}"); + Console.WriteLine($"Request failed: {ex.Message}"); } ``` +The specific internal exception types are not part of the public API, so you cannot distinguish between different redirect failure modes by exception type. If your application needs to respond differently to different kinds of redirect failures, consider lowering the `MaxRedirects` limit or disabling redirects entirely (omit `.WithRedirect()`) and handling 3xx responses yourself. + ::: info How it works See [Architecture: Request Pipeline](/architecture/pipeline) to understand how this feature fits into the processing pipeline. ::: diff --git a/docs/client/scenarios.md b/docs/client/scenarios.md index c49fae8c7..b949a0942 100644 --- a/docs/client/scenarios.md +++ b/docs/client/scenarios.md @@ -29,15 +29,17 @@ builder.Services.AddTurboHttpClient("rest-api", options => .WithCache(cache => { cache.MaxEntries = 500; - cache.MaxBodyBytes = 10 * 1024 * 1024; // 10 MiB + cache.MaxBodySize = 10 * 1024 * 1024; // 10 MiB }); ``` Usage: ```csharp -public class ApiService(ITurboHttpClientFactory factory) +public class ApiService(ITurboHttpClientFactory factory, ITokenProvider tokenProvider) { + private readonly ITokenProvider _tokenProvider = tokenProvider; + public async Task GetUserAsync(int userId, CancellationToken ct) { var client = factory.CreateClient("rest-api"); @@ -50,12 +52,12 @@ public class ApiService(ITurboHttpClientFactory factory) var json = await response.Content.ReadAsStringAsync(ct); return JsonSerializer.Deserialize(json)!; } -} -private string GetAccessToken() -{ - // Fetch from secure token store, refresh if expired, etc. - return _tokenProvider.GetToken(); + private string GetAccessToken() + { + // Fetch from secure token store, refresh if expired, etc. + return _tokenProvider.GetToken(); + } } ``` @@ -152,7 +154,6 @@ Web scraping, automated testing against web apps, session-based client applicati builder.Services.AddTurboHttpClient("batch-processor", options => { options.BaseAddress = new Uri("https://api.example.com"); - options.DefaultRequestVersion = HttpVersion.Version20; // HTTP/2 options.Http2.MaxConcurrentStreams = 100; // up to 100 concurrent streams per connection options.Http2.MaxConnectionsPerServer = 2; // reuse 2 connections }) @@ -171,6 +172,7 @@ public class BatchProcessor(ITurboHttpClientFactory factory) public async Task ProcessUrlsAsync(List urls, CancellationToken ct) { var client = factory.CreateClient("batch-processor"); + client.DefaultRequestVersion = HttpVersion.Version20; // default to HTTP/2 (set on the client instance, not options) var results = new ConcurrentBag<(string Url, int Status, string Body)>(); @@ -228,7 +230,7 @@ Batch URL fetching, parallel API polling, high-throughput data ingestion, distri **The problem:** Your service calls another internal service over HTTP/2. Connection setup needs a 5-second timeout, individual requests have a 10-second timeout. If the service is briefly unavailable, retry automatically. -**Features in play:** `ConnectTimeout` + `Timeout` for timeout management, `.WithRetry()` for resilience, HTTP/2 for efficiency. +**Features in play:** `ConnectTimeout` (in options) + `Timeout` (on client instance) for timeout management, `.WithRetry()` for resilience, HTTP/2 for efficiency. ```csharp // DI Registration @@ -236,8 +238,6 @@ builder.Services.AddTurboHttpClient("internal-service", options => { options.BaseAddress = new Uri("http://internal-service:8080"); options.ConnectTimeout = TimeSpan.FromSeconds(5); // TCP connect timeout - options.Timeout = TimeSpan.FromSeconds(10); // request timeout (for GET, HEAD, PUT, DELETE, OPTIONS) - options.DefaultRequestVersion = HttpVersion.Version20; // HTTP/2 }) .WithDecompression() // some responses may be compressed .WithRetry(retry => @@ -247,6 +247,14 @@ builder.Services.AddTurboHttpClient("internal-service", options => }); ``` +Then set the request timeout on the client instance: + +```csharp +var client = factory.CreateClient("internal-service"); +client.Timeout = TimeSpan.FromSeconds(10); // per-request timeout +client.DefaultRequestVersion = HttpVersion.Version20; // default to HTTP/2 (set on the client instance, not options) +``` + Usage: ```csharp @@ -281,114 +289,55 @@ Internal service-to-service communication, calling backend APIs from frontend se --- -## Akka.Streams Integration +## Direct Channel-Based Processing -**The problem:** You're already using Akka.Streams for data processing and want to plug TurboHTTP's channel API into an Akka.Streams graph — applying backpressure, throttling, transformation, and fan-out using the full Akka.Streams DSL. +**The problem:** You want to drive request/response processing yourself without `SendAsync()` — perhaps to implement custom backpressure logic, or to coordinate TurboHTTP with other async systems. -**Features in play:** `client.Requests` / `client.Responses` channels bridged to Akka.Streams via `ChannelSource.FromReader()` and `ChannelSink.AsWriter()`. +**Features in play:** `client.Requests` (a `ChannelWriter`) and `client.Responses` (a `ChannelReader`). -### Setup +TurboHTTP's channel API lets you: -```csharp -builder.Services.AddTurboHttpClient("stream-api", options => -{ - options.BaseAddress = new Uri("https://api.example.com/"); - options.Http2.MaxConcurrentStreams = 100; -}) -.WithRetry() -.WithDecompression(); -``` - -### Bridge Channels to Akka.Streams +1. Write requests directly to `client.Requests.WriteAsync(request)` instead of calling `SendAsync()` +2. Read responses from `client.Responses.ReadAllAsync()` in a loop +3. Both channels support `CancellationToken` for cancellation -TurboHTTP exposes `ChannelWriter` and `ChannelReader` on the client. Use Akka.Streams' `ChannelSource` and `ChannelSink` to bridge these into a stream graph: +This is useful if you want to coordinate request submission and response collection independently, or if you're building a custom orchestration layer. Example: ```csharp -using Akka.Streams; -using Akka.Streams.Dsl; +var client = factory.CreateClient("my-client"); -var client = factory.CreateClient("stream-api"); -client.DefaultRequestVersion = HttpVersion.Version20; - -var materializer = actorSystem.Materializer(); - -// Bridge: ChannelReader → Akka.Streams Source -var responseSource = ChannelSource.FromReader(client.Responses); - -// Bridge: Akka.Streams Sink → ChannelWriter -var requestSink = ChannelSink.AsWriter(client.Requests); -``` - -### Example: Throttled Request Pipeline - -Feed URLs through an Akka.Streams graph that throttles requests, sends them via TurboHTTP, and processes responses — all with backpressure end to end: - -```csharp -var urls = Enumerable.Range(1, 10_000) - .Select(id => $"items/{id}"); - -// Request pipeline: throttle → build request → send to TurboHTTP -Source.From(urls) - .Throttle(50, TimeSpan.FromSeconds(1), 10, ThrottleMode.Shaping) - .Select(url => new HttpRequestMessage(HttpMethod.Get, url)) - .RunWith(requestSink, materializer); - -// Response pipeline: receive from TurboHTTP → deserialize → process -await responseSource - .SelectAsync(4, async response => - { - var body = await response.Content.ReadFromJsonAsync(); - return body; - }) - .Where(item => item is not null) - .RunForeach(item => +// Producer task: submit requests without waiting for responses +var producer = Task.Run(async () => +{ + foreach (var url in urls) { - Console.WriteLine($"Processed: {item!.Name}"); - }, materializer); -``` - -### Example: Fan-Out with BroadcastHub + var request = new HttpRequestMessage(HttpMethod.Get, url); + await client.Requests.WriteAsync(request, ct); + } + client.Requests.Complete(); // Signal no more requests +}, ct); -Share a single TurboHTTP response stream across multiple consumers: +// Consumer task: process responses as they arrive +var consumer = Task.Run(async () => +{ + await foreach (var response in client.Responses.ReadAllAsync(ct)) + { + var body = await response.Content.ReadAsStringAsync(ct); + Console.WriteLine($"{response.StatusCode}: {body}"); + response.Dispose(); + } +}, ct); -```csharp -// Create a broadcast hub from the response source -var (broadcastSink, broadcastSource) = BroadcastHub.Sink(256) - .PreMaterialize(materializer); - -// Feed TurboHTTP responses into the hub -responseSource.RunWith(broadcastSink, materializer); - -// Consumer 1: log all responses -broadcastSource - .RunForeach(r => - Console.WriteLine($"[log] {r.RequestMessage?.RequestUri} → {r.StatusCode}"), - materializer); - -// Consumer 2: collect only successful responses -broadcastSource - .Where(r => r.IsSuccessStatusCode) - .SelectAsync(4, async r => await r.Content.ReadAsStringAsync()) - .RunForeach(body => ProcessResult(body), materializer); +await Task.WhenAll(producer, consumer); ``` -**How they interact:** - -- **ChannelSource/ChannelSink** bridge `System.Threading.Channels` to Akka.Streams without copying — backpressure flows through the channel boundary naturally -- **Throttle** limits request rate at the Akka.Streams level; when the sink can't keep up, backpressure pauses the throttle -- **SelectAsync** allows parallel response deserialization while preserving ordering -- **BroadcastHub** lets multiple consumers process the same response stream independently -- TurboHTTP's built-in **retry** and **decompression** still apply — the Akka.Streams graph sees clean, retried, decompressed responses - -::: tip When to use this pattern -Use this when you need stream-level control that the channel API alone doesn't provide: throttling, fan-out, windowing, grouping, merging multiple clients, or integrating TurboHTTP into a larger Akka.Streams data pipeline. -::: +For stream processing with backpressure, throttling, merging, or fan-out — use Akka.Streams directly with Akka.Streams adapters, or write your own adapter that bridges the channels to your stream DSL. --- ## Combining These Patterns -The four scenarios above show different feature combinations, but there is no rule against mixing them further. For example: +The scenarios above show different feature combinations, but there is no rule against mixing them further. For example: - **Authenticated batch processor:** Add `.UseRequest()` to inject a Bearer token into every request in a batch job. - **Cached microservice:** Add `.WithCache()` to an internal service call to avoid redundant backend queries. diff --git a/docs/client/troubleshooting.md b/docs/client/troubleshooting.md index 4735ad58e..a046f13d1 100644 --- a/docs/client/troubleshooting.md +++ b/docs/client/troubleshooting.md @@ -66,7 +66,7 @@ Per-request overrides are also supported via `HttpRequestMessage.Version`. ### Too Many Redirects -**Symptom:** `RedirectException` with `RedirectError.MaxRedirectsExceeded`. +**Symptom:** You receive a 3xx redirect response instead of the final destination response — TurboHTTP stopped following redirects after hitting the configured limit. **Fix:** The server is returning a redirect loop. Either fix the server or increase the redirect limit via the builder: @@ -82,7 +82,7 @@ To debug, remove the `.WithRedirect()` call entirely and inspect the redirect re ### HTTPS to HTTP Downgrade Blocked -**Symptom:** `RedirectException` with `RedirectError.ProtocolDowngrade` on a redirect. +**Symptom:** A redirect from `https://` to `http://` is not followed — TurboHTTP returns the 3xx redirect response instead of following it. **Cause:** A server redirected from `https://` to `http://`, which TurboHTTP blocks by default for security. @@ -98,35 +98,45 @@ builder.Services.AddTurboHttpClient("my-api", options => ### POST Requests Are Not Retried -**By design.** POST is not idempotent — retrying it could create duplicate resources. Only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE) are retried automatically. +**By design.** POST, PATCH, and other non-idempotent methods are never automatically retried — retrying them could create duplicate resources or cause unintended side effects. Only idempotent methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) are retried automatically. -If you need to retry POST, configure a custom retry policy via the builder: +This behaviour **cannot be disabled or bypassed** via `RetryOptions`. The idempotency check is baked into the retry evaluator and cannot be configured away. + +If you need to retry POST in your application, implement retry logic in your own code: ```csharp -builder.Services.AddTurboHttpClient("my-client", options => +var maxRetries = 3; +for (var attempt = 1; attempt <= maxRetries; attempt++) { - options.BaseAddress = new Uri("https://api.example.com"); -}) -.WithRetry(retry => { retry.MaxRetries = 3; }); + try + { + using var response = await client.SendAsync(postRequest, ct); + return response; + } + catch (HttpRequestException ex) when (attempt < maxRetries) + { + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), ct); + } +} ``` -The built-in retry handles idempotent method detection and backoff automatically. +The built-in `.WithRetry()` handles idempotent method detection and backoff automatically — use it for GET, PUT, DELETE, etc., but implement custom retry logic for POST if needed. ### High Memory Usage **Possible causes:** -1. **Cache too large** — reduce `MaxEntries` or `MaxBodyBytes` when registering: +1. **Cache too large** — reduce `MaxEntries` or `MaxBodySize` when registering: ```csharp - .WithCache(c => { c.MaxEntries = 100; c.MaxBodyBytes = 10 * 1024 * 1024; }) + .WithCache(c => { c.MaxEntries = 100; c.MaxBodySize = 10 * 1024 * 1024; }) ``` 2. **Response bodies not disposed** — always dispose `HttpResponseMessage` when done: ```csharp using var response = await client.SendAsync(request, ct); ``` -3. **CookieJar accumulating** — clear periodically if needed: +3. **CookieJar accumulating** — clear periodically if needed by calling `Clear()` on the `ICookieStore` you passed to `.WithCookies(store)`: ```csharp - cookieJar.Clear(); + store.Clear(); // store is the ICookieStore you provided to .WithCookies(store) ``` ### HTTP/2 Connection Failures diff --git a/docs/getting-started/architecture.md b/docs/getting-started/architecture.md index 12577c9da..474738634 100644 --- a/docs/getting-started/architecture.md +++ b/docs/getting-started/architecture.md @@ -1,6 +1,6 @@ # Architecture Overview -TurboHTTP is both an HTTP client and a standalone HTTP server, built on Akka.Streams. Both sides follow the same principle: composable pipeline stages connected by backpressure-aware streams. +TurboHTTP is both an HTTP client and a high-performance ASP.NET Core `IServer` — a drop-in Kestrel replacement — built on Akka.Streams. Both sides follow the same principle: composable pipeline stages connected by backpressure-aware streams. @@ -95,39 +95,38 @@ When a request arrives at TurboHTTP Server, it passes through a complementary pi ``` Incoming TCP/QUIC Connection ↓ -[Transport] — accepts connection via ListenerActor +[Transport] — accepts connection via ListenerActor, materialises ConnectionStage ↓ -[Protocol Decoder] — parses HTTP/1.0, 1.1, 2, or 3 bytes +[Protocol Negotiation] — detects HTTP version (ALPN over TLS, or byte-sniffing for plaintext) ↓ -[HttpContext Builder] — creates standard HttpContext from parsed request +[Protocol Decoder] — parses HTTP/1.0, 1.1, 2, or 3 bytes into IFeatureCollection ↓ -[Middleware Pipeline] — runs registered middleware (Use/Run/Map/MapWhen) +[ApplicationBridgeStage] — bridges to ASP.NET Core IHttpApplication ↓ -[Router] — matches request to registered route +[ASP.NET Core] + • Middleware pipeline (Use/Run/Map/MapWhen) + • Routing (matches request to endpoint) + • Parameter Binding (binds route, query, body, headers) + • Endpoint Execution (controller/Minimal API handler) ↓ -[Dispatcher] — handler function or actor +[Response Encoding] — converts response through protocol encoder back to bytes ↓ -[Parameter Binding] — binds route values, query, body, headers to handler parameters - ↓ -[Handler / Actor] — executes your code - ↓ -[Response] — writes response back through the pipeline +[Network] — sends over TCP or QUIC ``` -Each connection is managed by a `ConnectionActor` that owns the full Akka.Streams graph for that connection — from transport bytes through to response serialisation. +Each connection is managed by a `ConnectionStage` graph materialised inside the `ListenerActor` — from transport bytes through protocol decoding, bridging to ASP.NET Core's request processing, and response serialisation. ### Server Architecture -TurboHTTP Server is a standalone HTTP server — it does not use Kestrel. It uses its own transport layer via Servus.Akka.Transport. +TurboHTTP Server is an ASP.NET Core `IServer` implementation that replaces Kestrel, with its own TCP/QUIC transport via Servus.Akka.Transport. Middleware, routing, and parameter binding are delegated to standard ASP.NET Core. -- **TurboServerHostedService** — `IHostedService` entry point: creates ActorSystem and spawns the supervisor +- **TurboServer** — the `IServer` implementation registered via `builder.Host.UseTurboHttp()`; ASP.NET Core hosting calls `StartAsync()`, which creates the ActorSystem and spawns ServerSupervisorActor - **ServerSupervisorActor** — manages all listeners and tracks connection counts -- **ListenerActor** — binds TCP or QUIC transport, accepts incoming connections, spawns a ConnectionActor per client -- **ConnectionActor** — materialises the protocol engine + middleware + routing graph for a single client +- **ListenerActor** — binds TCP or QUIC transport, accepts incoming connections, and materialises a `ConnectionStage` graph that handles the full protocol lifecycle per client ### Transport Layer @@ -138,9 +137,9 @@ Protocol engines (`Http10ServerEngine`, `Http11ServerEngine`, `Http20ServerEngin ### Key Characteristics -- **Standalone**: Own TCP/QUIC transport — no Kestrel dependency +- **IServer replacement**: Replaces Kestrel with its own TCP/QUIC transport via Servus.Akka.Transport - **Actor-based**: Supervisor → Listener → Connection hierarchy with graceful shutdown and coordinated termination -- **Composable**: ASP.NET Core-style middleware pipeline with Use/Run/Map/MapWhen +- **ASP.NET Core native**: Works seamlessly with standard middleware, routing, and endpoint configuration - **Protocol-complete**: HTTP/1.0, 1.1, 2, and 3 with automatic ALPN negotiation --- diff --git a/docs/getting-started/client.md b/docs/getting-started/client.md index d7985d0fb..bfb5b1e8e 100644 --- a/docs/getting-started/client.md +++ b/docs/getting-started/client.md @@ -11,7 +11,7 @@ dotnet add package TurboHTTP ## 2. Register a Client ```csharp -using TurboHTTP; +using TurboHTTP.Client; var builder = WebApplication.CreateBuilder(args); @@ -26,6 +26,9 @@ var app = builder.Build(); ## 3. Send a Request ```csharp +using TurboHTTP.Client; +using System.Net.Http; + var factory = app.Services.GetRequiredService(); var client = factory.CreateClient("api"); @@ -42,11 +45,13 @@ Console.WriteLine(await response.Content.ReadAsStringAsync()); Features are opt-in via the fluent builder: ```csharp +using TurboHTTP.Client; + builder.Services.AddTurboHttpClient("api", options => { options.BaseAddress = new Uri("https://api.example.com"); }) -.WithRetry() // automatic retries for GET, PUT, DELETE +.WithRetry() // automatic retries for idempotent methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) .WithCache() // in-memory HTTP caching with ETag .WithCookies() // automatic cookie storage and injection .WithRedirect() // follow redirect chains @@ -60,6 +65,10 @@ Each `.With*()` method adds a pipeline stage. They compose — order doesn't mat For batch processing, use the channel-based API instead of `SendAsync`: ```csharp +using TurboHTTP.Client; +using System.Net.Http; +using System.Threading.Channels; + var client = factory.CreateClient("api"); // Producer: write requests without waiting diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index c38e18be0..0b11f183d 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -20,7 +20,7 @@ TurboHTTP has two sides — use either or both: | | Client | Server | |---|---|---| -| **What it does** | Makes HTTP requests with built-in retries, caching, cookies, and connection pooling | Handles HTTP requests with middleware, routing, and actor-based entity gateway | +| **What it does** | Makes HTTP requests with built-in retries, caching, cookies, and connection pooling | Serves HTTP/1.0, 1.1, 2, 3 as a drop-in ASP.NET Core IServer (Kestrel replacement); middleware, routing, Minimal APIs, and Controllers are standard ASP.NET Core; an optional actor-based entity gateway is available via the separate Servus.Akka.AspNetCore package | | **Get started** | [Client Quick Start →](./client) | [Server Quick Start →](./server) | | **Full docs** | [Client Guide →](/client/) | [Server Guide →](/server/) | @@ -68,7 +68,7 @@ await app.RunAsync(); ``` ::: tip About UseTurboHttp -TurboHTTP Server is a fully standalone HTTP server built on Akka.Streams with its own TCP/QUIC transport layer. Register it on `builder.Host` using `UseTurboHttp()` — it does not use or depend on Kestrel. +TurboHTTP Server is a high-performance IServer implementation for ASP.NET Core built on Akka.Streams; it replaces Kestrel as the transport layer and integrates with standard ASP.NET Core middleware, routing, and DI. Register it on `builder.Host` using `UseTurboHttp()`. ::: ## Next Steps diff --git a/docs/getting-started/migration.md b/docs/getting-started/migration.md index c233af693..e8bef493b 100644 --- a/docs/getting-started/migration.md +++ b/docs/getting-started/migration.md @@ -112,7 +112,6 @@ No Polly dependency needed. TurboHTTP automatically: - Retries only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE) - Never retries POST or PATCH - Respects `Retry-After` headers -- Applies exponential backoff Retry behavior is controlled via the built-in `.WithRetry()` builder extension — see [Automatic Retries](/client/retries) for custom policies. diff --git a/docs/getting-started/server.md b/docs/getting-started/server.md index d903ea66b..a5237bbfd 100644 --- a/docs/getting-started/server.md +++ b/docs/getting-started/server.md @@ -49,6 +49,8 @@ curl http://localhost:5100/ ## 4. Add HTTPS ```csharp +using TurboHTTP.Server; + builder.Host.UseTurboHttp(options => { options.ListenLocalhost(5100); @@ -66,7 +68,7 @@ TurboHTTP is a transport-level replacement — it handles TCP/QUIC connections, | | Kestrel | TurboHTTP | |---|---------|-----------| -| Transport | libuv / SocketsHttpHandler | Akka.Streams + Servus.Akka.Transport | +| Transport | Sockets (Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets) | Akka.Streams + Servus.Akka.Transport | | Connection model | Thread pool | Actor per connection | | Protocols | HTTP/1.1, HTTP/2, HTTP/3 | HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3 | | Backpressure | Pipe-based | Akka.Streams reactive streams | diff --git a/docs/likec4/model-pipeline.c4 b/docs/likec4/model-pipeline.c4 index 8d4307b66..f722463f9 100644 --- a/docs/likec4/model-pipeline.c4 +++ b/docs/likec4/model-pipeline.c4 @@ -3,14 +3,14 @@ // for both client and server request processing pipelines. // // CLIENT PIPELINE STACKING (outermost -> innermost): -// Handler -> Tracing -> Redirect -> Cookie -> Retry -> Expect100 -> Cache -> ContentEncoding -> AltSvc -> Engine +// Tracing -> Handler -> Redirect -> Cookie -> Retry -> Expect100 -> Cache -> ContentEncoding -> AltSvc -> Engine // // CLIENT REQUEST CHAIN (app -> Network): -// enricher -> handler -> tracing -> redirect -> cookie -> retry -> expect100 -> cache -> contentEncoding +// enricher -> tracing -> handler -> redirect -> cookie -> retry -> expect100 -> cache -> contentEncoding // -> altSvc -> engine -> [version engines -> Network] // // CLIENT RESPONSE CHAIN (Network -> app): -// engine -> altSvc -> contentEncoding -> cache -> expect100 -> retry -> cookie -> redirect -> tracing -> handler -> app +// engine -> altSvc -> contentEncoding -> cache -> expect100 -> retry -> cookie -> redirect -> handler -> tracing -> app // // CLIENT FEEDBACK LOOPS // redirect --- redirect request --> cookie (re-enters with cookie injection for new URL) @@ -24,9 +24,9 @@ model { // Request chain (outermost -> innermost) turbohttp.client.TurboHttpClient -[flows]-> turbohttp.streams.RequestEnricher 'request' - turbohttp.streams.RequestEnricher -[flows]-> turbohttp.streams.HandlerBidiStage 'request (defaults applied)' - turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.streams.TracingBidiStage 'request' - turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.streams.RedirectBidiStage 'request' + turbohttp.streams.RequestEnricher -[flows]-> turbohttp.streams.TracingBidiStage 'request' + turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.streams.HandlerBidiStage 'request (defaults applied)' + turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.streams.RedirectBidiStage 'request' turbohttp.streams.RedirectBidiStage -[flows]-> turbohttp.streams.CookieBidiStage 'request' turbohttp.streams.CookieBidiStage -[flows]-> turbohttp.streams.RetryBidiStage 'request (cookies injected)' turbohttp.streams.RetryBidiStage -[flows]-> turbohttp.streams.ExpectContinueBidiStage 'request' @@ -82,9 +82,9 @@ model { turbohttp.streams.ExpectContinueBidiStage -[flows]-> turbohttp.streams.RetryBidiStage 'response' turbohttp.streams.RetryBidiStage -[flows]-> turbohttp.streams.CookieBidiStage 'response' turbohttp.streams.CookieBidiStage -[flows]-> turbohttp.streams.RedirectBidiStage 'response (cookies stored)' - turbohttp.streams.RedirectBidiStage -[flows]-> turbohttp.streams.TracingBidiStage 'response' - turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.streams.HandlerBidiStage 'response (traced)' - turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.client.TurboHttpClient 'response' + turbohttp.streams.RedirectBidiStage -[flows]-> turbohttp.streams.HandlerBidiStage 'response' + turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.streams.TracingBidiStage 'response (traced)' + turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.client.TurboHttpClient 'response' // Feedback: cache hit bypasses engine, re-enters at Retry turbohttp.streams.CacheBidiStage -[feedback]-> turbohttp.streams.RetryBidiStage 'cached response (bypasses network)' diff --git a/docs/likec4/model.c4 b/docs/likec4/model.c4 index 7adf51468..890b772ab 100644 --- a/docs/likec4/model.c4 +++ b/docs/likec4/model.c4 @@ -38,20 +38,20 @@ model { ClientStreamManager = component 'ClientStreamManager' { #client - technology 'Class' - description 'Materialises the Akka.Streams pipeline and manages stream lifecycle' + technology 'ReceiveActor' + description 'Per-client actor: routes consumer registrations and creates the StreamOwner that owns the pipeline' } StreamOwner = component 'StreamOwner' { #client - technology 'Class' - description 'Owns the materialised stream lifecycle: starts, monitors, and restarts the pipeline on failure' + technology 'ReceiveActor' + description 'Per-client actor: materialises the Akka.Streams pipeline, registers consumers, and monitors and restarts it on failure' } TurboClientOptions = component 'TurboClientOptions' { #client - technology 'Record' - description 'BaseAddress, DefaultRequestVersion, DefaultRequestHeaders' + technology 'Class' + description 'BaseAddress, ConnectTimeout, and nested Http1/Http2/Http3 options' } } @@ -205,7 +205,7 @@ model { ConnectionActor = actor 'ConnectionActor' { #server technology 'ReceiveActor' - description 'Per-connection actor: materialises the server-side Akka.Streams graph (protocol engine + middleware + routing) for a single client connection' + description 'Per-connection actor: materialises the server-side Akka.Streams graph, selecting the appropriate protocol engine via ProtocolRouter and bridging to ApplicationBridgeStage; middleware and routing are handled by ASP.NET Core' } ProtocolRouter = component 'ProtocolRouter' { @@ -345,17 +345,17 @@ model { turbohttp.client.ClientStreamManager -> turbohttp.client.StreamOwner 'Manages lifecycle' turbohttp.client.ITurboHttpClient -> turbohttp.client.TurboClientOptions 'Reads configuration' - turbohttp.client.ClientStreamManager -> turbohttp.streams.RequestEnricher 'Wraps with enrichment' - turbohttp.client.ClientStreamManager -> turbohttp.streams.HandlerBidiStage 'Wraps with handlers' - turbohttp.client.ClientStreamManager -> turbohttp.streams.TracingBidiStage 'Wraps with tracing' - turbohttp.client.ClientStreamManager -> turbohttp.streams.RedirectBidiStage 'Wraps with redirects' - turbohttp.client.ClientStreamManager -> turbohttp.streams.CookieBidiStage 'Wraps with cookies' - turbohttp.client.ClientStreamManager -> turbohttp.streams.RetryBidiStage 'Wraps with retry' - turbohttp.client.ClientStreamManager -> turbohttp.streams.ExpectContinueBidiStage 'Wraps with 100-continue' - turbohttp.client.ClientStreamManager -> turbohttp.streams.CacheBidiStage 'Wraps with caching' - turbohttp.client.ClientStreamManager -> turbohttp.streams.ContentEncodingBidiStage 'Wraps with compression' - turbohttp.client.ClientStreamManager -> turbohttp.streams.AltSvcBidiStage 'Wraps with Alt-Svc' - turbohttp.client.ClientStreamManager -> turbohttp.streams.Engine 'Wraps with engine' + turbohttp.client.StreamOwner -> turbohttp.streams.RequestEnricher 'Enriches requests before the pipeline' + turbohttp.client.StreamOwner -> turbohttp.streams.HandlerBidiStage 'Wraps with handlers' + turbohttp.client.StreamOwner -> turbohttp.streams.TracingBidiStage 'Wraps with tracing' + turbohttp.client.StreamOwner -> turbohttp.streams.RedirectBidiStage 'Wraps with redirects' + turbohttp.client.StreamOwner -> turbohttp.streams.CookieBidiStage 'Wraps with cookies' + turbohttp.client.StreamOwner -> turbohttp.streams.RetryBidiStage 'Wraps with retry' + turbohttp.client.StreamOwner -> turbohttp.streams.ExpectContinueBidiStage 'Wraps with 100-continue' + turbohttp.client.StreamOwner -> turbohttp.streams.CacheBidiStage 'Wraps with caching' + turbohttp.client.StreamOwner -> turbohttp.streams.ContentEncodingBidiStage 'Wraps with compression' + turbohttp.client.StreamOwner -> turbohttp.streams.AltSvcBidiStage 'Wraps with Alt-Svc' + turbohttp.client.StreamOwner -> turbohttp.streams.Engine 'Wraps with engine' turbohttp.streams.Http10ClientEngine -> servus.TcpConnectionStage 'TCP transport' turbohttp.streams.Http11ClientEngine -> servus.TcpConnectionStage 'TCP transport' diff --git a/docs/likec4/views-scenarios.c4 b/docs/likec4/views-scenarios.c4 index aa92bde6c..6eeace26a 100644 --- a/docs/likec4/views-scenarios.c4 +++ b/docs/likec4/views-scenarios.c4 @@ -1,7 +1,7 @@ // TurboHTTP — Scenario Dynamic Views // Four playable end-to-end request sequences (HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3). // Pipeline stacking order (outermost -> innermost): -// RequestEnricher -> Handler -> Tracing -> Redirect -> Cookie -> Retry -> ExpectContinue -> Cache -> ContentEncoding -> AltSvc -> Engine +// RequestEnricher -> Tracing -> Handler -> Redirect -> Cookie -> Retry -> ExpectContinue -> Cache -> ContentEncoding -> AltSvc -> Engine views { dynamic view scenarioHttp10 { @@ -10,13 +10,16 @@ views { app -> turbohttp.client.ITurboHttpClient 'request' turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request' - turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)' - turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request' + turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)' + turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request' turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request' - turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)' + turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)' + turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request' turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)' - turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)' + turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request' turbohttp.streams.Engine -> turbohttp.streams.Http10ClientEngine 'HTTP/1.0' turbohttp.streams.Http10ClientEngine -> turbohttp.streams.Http10ClientConnectionStage 'request' turbohttp.streams.Http10ClientConnectionStage -> servus.TcpConnectionStage 'outbound bytes' @@ -28,14 +31,16 @@ views { network -> servus.TcpConnectionStage 'TCP/TLS bytes' servus.TcpConnectionStage -> turbohttp.streams.Http10ClientConnectionStage 'inbound bytes' - turbohttp.streams.Http10ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response' + turbohttp.streams.Http10ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response' turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)' - turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)' + turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response' turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response' turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)' - turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)' - turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response' + turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)' + turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response' turbohttp.client.ITurboHttpClient -> app 'response' } @@ -45,13 +50,16 @@ views { app -> turbohttp.client.ITurboHttpClient 'request' turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request' - turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)' - turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request' + turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)' + turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request' turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request' - turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)' + turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)' + turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request' turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)' - turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)' + turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request' turbohttp.streams.Engine -> turbohttp.streams.Http11ClientEngine 'HTTP/1.1' turbohttp.streams.Http11ClientEngine -> turbohttp.streams.Http11ClientConnectionStage 'request' turbohttp.streams.Http11ClientConnectionStage -> servus.TcpConnectionStage 'outbound bytes' @@ -63,14 +71,16 @@ views { network -> servus.TcpConnectionStage 'TCP/TLS bytes' servus.TcpConnectionStage -> turbohttp.streams.Http11ClientConnectionStage 'inbound bytes' - turbohttp.streams.Http11ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response' + turbohttp.streams.Http11ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response' turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)' - turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)' + turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response' turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response' turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)' - turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)' - turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response' + turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)' + turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response' turbohttp.client.ITurboHttpClient -> app 'response' } @@ -80,13 +90,16 @@ views { app -> turbohttp.client.ITurboHttpClient 'request' turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request' - turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)' - turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request' + turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)' + turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request' turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request' - turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)' + turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)' + turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request' turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)' - turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)' + turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request' turbohttp.streams.Engine -> turbohttp.streams.Http20ClientEngine 'HTTP/2' turbohttp.streams.Http20ClientEngine -> turbohttp.streams.Http20ClientConnectionStage 'request' turbohttp.streams.Http20ClientConnectionStage -> servus.TcpConnectionStage 'outbound bytes' @@ -98,14 +111,16 @@ views { network -> servus.TcpConnectionStage 'TCP/TLS bytes' servus.TcpConnectionStage -> turbohttp.streams.Http20ClientConnectionStage 'inbound bytes' - turbohttp.streams.Http20ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response' + turbohttp.streams.Http20ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response' turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)' - turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)' + turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response' turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response' turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)' - turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)' - turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response' + turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)' + turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response' turbohttp.client.ITurboHttpClient -> app 'response' } @@ -115,13 +130,16 @@ views { app -> turbohttp.client.ITurboHttpClient 'request' turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request' - turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)' - turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request' + turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)' + turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request' turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request' - turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)' + turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)' + turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request' turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)' - turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)' + turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request' turbohttp.streams.Engine -> turbohttp.streams.Http30ClientEngine 'HTTP/3' turbohttp.streams.Http30ClientEngine -> turbohttp.streams.Http30ClientConnectionStage 'request' turbohttp.streams.Http30ClientConnectionStage -> servus.QuicConnectionStage 'outbound bytes' @@ -133,14 +151,16 @@ views { network -> servus.QuicConnectionStage 'QUIC bytes' servus.QuicConnectionStage -> turbohttp.streams.Http30ClientConnectionStage 'inbound bytes' - turbohttp.streams.Http30ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response' + turbohttp.streams.Http30ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response' + turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response' turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)' - turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)' + turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)' + turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response' turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response' turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)' - turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response' - turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)' - turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response' + turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response' + turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)' + turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response' turbohttp.client.ITurboHttpClient -> app 'response' } diff --git a/docs/scenarios.md b/docs/scenarios.md index 33efd3a0b..7568669e6 100644 --- a/docs/scenarios.md +++ b/docs/scenarios.md @@ -34,10 +34,14 @@ TurboHTTP reuses an existing `ActorSystem` from DI if one is registered (e.g. vi ## Real-Time SSE Streaming -Server-Sent Events let you push data to clients over a long-lived HTTP connection. TurboHTTP makes this trivial — return an Akka Streams `Source` wrapped in `TurboStreamResults.EventStream`, and the framework handles SSE framing, connection lifecycle, and backpressure for you. +Server-Sent Events let you push data to clients over a long-lived HTTP connection. TurboHTTP makes this trivial — return an Akka Streams `Source` wrapped in `AkkaResults.ServerSentEvent`, and the framework handles SSE framing, connection lifecycle, and backpressure for you. + +Streaming helpers come from the `Servus.Akka.AspNetCore` package and require an `IMaterializer` instance (typically injected from DI). ```csharp -app.MapGet("/events/orders", (HttpContext ctx, IOrderEventSource orderEvents) => +using Servus.Akka.AspNetCore; + +app.MapGet("/events/orders", (HttpContext ctx, IOrderEventSource orderEvents, IMaterializer materializer) => { var events = orderEvents .AsSource() @@ -46,7 +50,7 @@ app.MapGet("/events/orders", (HttpContext ctx, IOrderEventSource orderEvents) => EventType: e.GetType().Name, Id: e.OrderId.ToString())); - return TurboStreamResults.EventStream(events); + return AkkaResults.ServerSentEvent(events, materializer); }); ``` @@ -58,18 +62,21 @@ The `Source` is materialized when the client connects and torn down when they di ## Raw Byte Streaming -When you need to stream binary data — file downloads, video, sensor feeds — you want bytes to flow from the source to the network without piling up in memory. `TurboStreamResults.Stream` takes an Akka Streams `Source` of byte chunks and pipes it directly into the HTTP response body. +When you need to stream binary data — file downloads, video, sensor feeds — you want bytes to flow from the source to the network without piling up in memory. `AkkaResults.Stream` takes an Akka Streams `Source` of byte chunks and pipes it directly into the HTTP response body. ```csharp -app.MapGet("/files/{fileId}", (HttpContext ctx, IFileStore fileStore, string fileId) => +using Servus.Akka.AspNetCore; + +app.MapGet("/files/{fileId}", (HttpContext ctx, IFileStore fileStore, string fileId, IMaterializer materializer) => { var metadata = fileStore.GetMetadata(fileId); - var bytes = Akka.Streams.IO.FileIO + var bytes = Akka.Streams.Dsl.FileIO .FromFile(new FileInfo(metadata.Path), chunkSize: 8 * 1024) - .Select(chunk => (ReadOnlyMemory)chunk.Memory); + .Select(chunk => (ReadOnlyMemory)chunk.ToArray()) + .MapMaterializedValue(_ => Akka.NotUsed.Instance); - return TurboStreamResults.Stream(bytes, contentType: metadata.ContentType); + return AkkaResults.Stream(bytes, materializer, contentType: metadata.ContentType); }); ``` @@ -114,19 +121,21 @@ Over HTTP/2, all 100 requests multiplex on a single connection. Responses arrive TurboHTTP doesn't just use Akka Streams for internal plumbing — it exposes the full operator toolkit for you to shape, merge, and throttle data before it hits the wire. Every operator in the pipeline participates in backpressure, from the data source all the way to the client's TCP receive window. ```csharp -app.MapGet("/metrics/live", (HttpContext ctx, IMetricsSource metrics) => +using Servus.Akka.AspNetCore; + +app.MapGet("/metrics/live", (HttpContext ctx, IMetricsSource metrics, IMaterializer materializer) => { var cpuMetrics = metrics.CpuEvents(); var memoryMetrics = metrics.MemoryEvents(); var merged = cpuMetrics .Merge(memoryMetrics) - .Throttle(100, TimeSpan.FromSeconds(1), ThrottleMode.Shaping) + .Throttle(100, TimeSpan.FromSeconds(1), 100, ThrottleMode.Shaping) .Select(m => new ServerSentEvent( Data: m.ToJson(), EventType: m.Category)); - return TurboStreamResults.EventStream(merged); + return AkkaResults.ServerSentEvent(merged, materializer); }); ``` diff --git a/docs/server/configuration.md b/docs/server/configuration.md index 0b8ebde5d..3524abe36 100644 --- a/docs/server/configuration.md +++ b/docs/server/configuration.md @@ -16,9 +16,10 @@ builder.Host.UseTurboHttp(options => | `HandlerTimeout` | `TimeSpan` | 30s | Maximum time for a request handler to complete | | `HandlerGracePeriod` | `TimeSpan` | 5s | Extra time after handler timeout before force-closing | | `GracefulShutdownTimeout` | `TimeSpan` | 30s | Time to drain connections during shutdown | -| `BodyBufferThreshold` | `int` | 64 * 1024 | Request body buffer size before streaming | | `BodyConsumptionTimeout` | `TimeSpan` | 30s | Time for the app to consume the request body | | `ResponseBodyChunkSize` | `int` | 16 * 1024 | Chunk size for response body writes | +| `MaxOutboundCoalesceCount` | `int` | 32 | Coalesce factor for outbound writes — frames are merged up to factor × 16 KiB per transport write | +| `AllowResponseHeaderCompression` | `bool` | true | Whether response headers may use Huffman compression (HPACK/QPACK); disable to mitigate CRIME/BREACH-style attacks | ## Connection Limits @@ -27,15 +28,17 @@ Access via `options.Limits`. | Property | Type | Default | Description | |----------|------|---------|-------------| | `MaxConcurrentConnections` | `int` | 0 (unlimited) | Maximum concurrent connections | -| `MaxConcurrentUpgradedConnections` | `int` | 0 (unlimited) | Maximum upgraded connections (WebSocket) | -| `MaxRequestBodySize` | `long` | 30 * 1024 * 1024 | Global max request body size | +| `MaxRequestBodySize` | `long` | 30,000,000 (~28.6 MiB) | Global max request body size (matches Kestrel) | +| `MaxResponseBufferSize` | `long` | 64 * 1024 | Maximum per-stream response write buffer | +| `MaxRequestBufferSize` | `long?` | 1 MiB | Transport input buffer before backpressure is applied (`null` = unlimited) | | `MaxRequestHeaderCount` | `int` | 100 | Maximum request headers | | `MaxRequestHeadersTotalSize` | `int` | 32 * 1024 | Maximum total header bytes | +| `MaxResetStreamsPerWindow` | `int` | 200 | Maximum HTTP/2 stream resets tolerated in a sliding window before the connection is closed (Rapid Reset / CVE-2023-44487 mitigation). Set to 0 to disable. | | `KeepAliveTimeout` | `TimeSpan` | 130s | Idle connection timeout | | `RequestHeadersTimeout` | `TimeSpan` | 30s | Time to receive request headers | -| `MinRequestBodyDataRate` | `double` | 0 | Minimum body bytes/sec (0 = disabled) | +| `MinRequestBodyDataRate` | `double` | 240 | Minimum body bytes/sec (0 = disabled) | | `MinRequestBodyDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing body rate | -| `MinResponseDataRate` | `double` | 0 | Minimum response bytes/sec (0 = disabled) | +| `MinResponseDataRate` | `double` | 240 | Minimum response bytes/sec (0 = disabled) | | `MinResponseDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing response rate | ## HTTP/1.x Options @@ -48,11 +51,16 @@ Access via `options.Http1`. | `MaxRequestTargetLength` | `int` | 8192 | Maximum bytes for the request target (URL) | | `MaxPipelinedRequests` | `int` | 16 | Maximum queued pipelined requests | | `MaxChunkExtensionLength` | `int` | 4096 | Maximum bytes for chunk extensions | +| `MaxBufferedRequestBodySize` | `int` | 64 * 1024 | Request bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe | | `BodyReadTimeout` | `TimeSpan` | 30s | Timeout for reading request body | -| `MaxRequestBodySize` | `long` | 30_000_000 | HTTP/1.x-specific body size limit | -| `MaxHeaderListSize` | `int` | 32 * 1024 | Maximum total header bytes | +| `MaxHeaderListSize` | `int?` | null (uses global) | Max total header bytes (null = uses `Limits.MaxRequestHeadersTotalSize`) | +| `MaxRequestBodySize` | `long?` | null (uses global) | HTTP/1.x-specific body size limit | | `KeepAliveTimeout` | `TimeSpan?` | null (uses global) | Per-protocol keep-alive override | | `RequestHeadersTimeout` | `TimeSpan?` | null (uses global) | Per-protocol headers timeout override | +| `MinRequestBodyDataRate` | `double?` | null (uses global) | Per-protocol minimum body bytes/sec override | +| `MinRequestBodyDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing body rate | +| `MinResponseDataRate` | `double?` | null (uses global) | Per-protocol minimum response bytes/sec override | +| `MinResponseDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing response rate | ## HTTP/2 Options @@ -62,16 +70,23 @@ Access via `options.Http2`. |----------|------|---------|-------------| | `MaxConcurrentStreams` | `int` | 100 | Maximum concurrent streams per connection | | `InitialConnectionWindowSize` | `int` | 1 * 1024 * 1024 | Connection-level flow control window | -| `InitialStreamWindowSize` | `int` | 768 * 1024 | Per-stream flow control window | +| `InitialStreamWindowSize` | `int` | 768 * 1024 | Per-stream flow control window (starting point for adaptive scaling) | +| `MaxStreamWindowSize` | `int` | 8 * 1024 * 1024 | Upper bound for adaptive per-stream window growth | +| `WindowScaleThresholdMultiplier` | `double` | 1.0 | Threshold multiplier for adaptive window growth; higher values grow less eagerly | +| `EnableAdaptiveWindowScaling` | `bool` | true | Grow the per-stream receive window based on measured throughput and RTT | | `MaxFrameSize` | `int` | 16 * 1024 | Maximum HTTP/2 frame payload size | -| `MaxHeaderListSize` | `int` | 32 * 1024 | Maximum total header bytes | +| `MaxHeaderListSize` | `int?` | null (uses global) | Max total header bytes (null = uses `Limits.MaxRequestHeadersTotalSize`) | | `HeaderTableSize` | `int` | 4 * 1024 | HPACK dynamic table size | -| `MaxRequestBodySize` | `long` | 30_000_000 | HTTP/2-specific body size limit | -| `MaxResponseBufferSize` | `long` | 64 * 1024 | Response buffering before backpressure | -| `KeepAliveTimeout` | `TimeSpan` | 130s | Connection idle timeout | -| `RequestHeadersTimeout` | `TimeSpan` | 30s | Time to receive request headers | -| `MinRequestBodyDataRate` | `int` | 240 | Minimum body bytes/sec | -| `MinRequestBodyDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing rate | +| `MaxResponseBufferSize` | `long?` | null (uses global) | Response buffering before backpressure (null = uses `Limits.MaxResponseBufferSize`) | +| `MaxRequestBodySize` | `long?` | null (uses global) | HTTP/2-specific body size limit | +| `KeepAliveTimeout` | `TimeSpan?` | null (uses global) | Connection idle timeout | +| `KeepAlivePingDelay` | `TimeSpan` | infinite (disabled) | Idle time after the last received frame before the server sends a keep-alive PING | +| `KeepAlivePingTimeout` | `TimeSpan` | 20s | Max wait for a PING ACK before the connection is closed | +| `RequestHeadersTimeout` | `TimeSpan?` | null (uses global) | Time to receive request headers | +| `MinRequestBodyDataRate` | `double?` | null (uses global) | Minimum body bytes/sec | +| `MinRequestBodyDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing body rate | +| `MinResponseDataRate` | `double?` | null (uses global) | Minimum response bytes/sec | +| `MinResponseDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing response rate | ## HTTP/3 Options @@ -80,14 +95,43 @@ Access via `options.Http3`. | Property | Type | Default | Description | |----------|------|---------|-------------| | `MaxConcurrentStreams` | `int` | 100 | Maximum concurrent streams per connection | -| `MaxHeaderListSize` | `int` | 32 * 1024 | Maximum total header bytes | +| `MaxHeaderListSize` | `int?` | null (uses global) | Max total header bytes (null = uses `Limits.MaxRequestHeadersTotalSize`) | | `QpackMaxTableCapacity` | `int` | 0 | QPACK dynamic table capacity (0 = static only) | -| `EnableWebTransport` | `bool` | false | Enable WebTransport support | -| `MaxRequestBodySize` | `long` | 30_000_000 | HTTP/3-specific body size limit | -| `KeepAliveTimeout` | `TimeSpan` | 130s | Connection idle timeout | -| `RequestHeadersTimeout` | `TimeSpan` | 30s | Time to receive request headers | -| `MinRequestBodyDataRate` | `int` | 240 | Minimum body bytes/sec | -| `MinRequestBodyDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing rate | +| `QpackBlockedStreams` | `int` | 100 | Maximum concurrent QPACK-blocked streams | +| `MaxResponseBufferSize` | `long?` | null (uses global) | Per-stream response write buffer (null = uses `Limits.MaxResponseBufferSize`) | +| `MaxRequestBodySize` | `long?` | null (uses global) | HTTP/3-specific body size limit | +| `KeepAliveTimeout` | `TimeSpan?` | null (uses global) | Connection idle timeout | +| `RequestHeadersTimeout` | `TimeSpan?` | null (uses global) | Time to receive request headers | +| `MinRequestBodyDataRate` | `double?` | null (uses global) | Minimum body bytes/sec | +| `MinRequestBodyDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing body rate | +| `MinResponseDataRate` | `double?` | null (uses global) | Minimum response bytes/sec | +| `MinResponseDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing response rate | + +## Transport Buffers + +Per-endpoint backpressure thresholds for the pipes between the OS socket and the HTTP pipeline. Set via `TurboListenOptions.Transport`. All properties are nullable; each `null` property falls back to its protocol-optimized default individually (TCP buffers one pipe per connection, QUIC one pipe per stream), so you only set what you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`. + +| Property | Type | TCP Default | QUIC Default | Description | +|----------|------|-------------|--------------|-------------| +| `InputPauseThreshold` | `long?` | 1 MiB | 64 KiB | Bytes buffered on the read pipe before the OS socket is paused | +| `InputResumeThreshold` | `long?` | 512 KiB | 32 KiB | Buffered byte count at which reading resumes | +| `OutputPauseThreshold` | `long?` | 64 KiB | 64 KiB | Bytes buffered on the write pipe before the HTTP pipeline is paused | +| `OutputResumeThreshold` | `long?` | 32 KiB | 32 KiB | Buffered byte count at which writing resumes | +| `MinimumSegmentSize` | `int?` | 16 KiB | 4 KiB | Minimum pipe buffer segment size | + +```csharp +options.Listen(IPAddress.Any, 8080, listen => +{ + // Only the input thresholds are overridden; everything else keeps the TCP defaults + listen.Transport = new TransportBufferOptions + { + InputPauseThreshold = 2 * 1024 * 1024, + InputResumeThreshold = 1024 * 1024 + }; +}); +``` + +See the [Server API reference](/api/server#transport-buffer-options) for details. ## Example: Full Configuration diff --git a/docs/server/hosting.md b/docs/server/hosting.md index 7da367a8d..6b8da995f 100644 --- a/docs/server/hosting.md +++ b/docs/server/hosting.md @@ -45,12 +45,9 @@ TurboHTTP Server uses this actor structure: ActorSystem (turbo-server) ├── ServerSupervisorActor │ ├── ListenerActor (endpoint 127.0.0.1:5100) - │ │ ├── ConnectionActor (active connection 1) - │ │ ├── ConnectionActor (active connection 2) - │ │ └── ... + │ │ └── ConnectionStage (Akka.Streams GraphStage — handles all active connections) │ └── ListenerActor (endpoint 127.0.0.1:5101) - │ ├── ConnectionActor (active connection 3) - │ └── ... + │ └── ConnectionStage (Akka.Streams GraphStage — handles all active connections) ``` ### ServerSupervisorActor @@ -67,43 +64,38 @@ When shutdown begins, the supervisor tells all listeners to stop accepting new c Each endpoint has one listener. It: - Binds the transport (TCP port or QUIC/UDP port) -- Accepts incoming connections -- Creates a ConnectionActor for each new connection +- Accepts incoming connections via a `ConnectionStage` - Enforces MaxConcurrentConnections limit (when configured) -When a connection arrives, the listener materializes the full HTTP processing pipeline into a new actor and tells it to run. +When a connection arrives, the `ConnectionStage` GraphStage materializes the full HTTP processing pipeline as a sub-graph for that connection. -### ConnectionActor +### ConnectionStage -Each active connection runs in a ConnectionActor. It: -- Materializes the complete Akka.Streams graph: +Connections are not managed by per-connection actors. Instead, each `ListenerActor` runs a single `ConnectionStage` — an Akka.Streams `GraphStage` — that sub-fuses a new streaming pipeline for every incoming connection. It: +- Materializes the complete Akka.Streams graph per connection: - Transport inbound/outbound flow - Protocol engine (HTTP/1.0, 1.1, 2, or 3) - ApplicationBridgeStage → IHttpApplication<TContext> → ASP.NET Core pipeline -- Holds a kill switch to stop processing cleanly -- Reports completion (success, error, or shutdown) back to the supervisor - -Once the handler completes or the connection closes, the ConnectionActor terminates and reports the completion reason. +- Holds a shared drain kill switch to stop all connections cleanly during shutdown +- Tracks active connection count and completes the stage when drained ## Connection Lifecycle From the moment a client connects until it closes, here's what happens: 1. **Connection arrives**: ListenerActor receives an incoming connection from the transport -2. **ConnectionActor spawned**: A new actor is created for this connection, watched by the listener -3. **Pipeline materialized**: The full Akka.Streams graph is wired up: +2. **Pipeline materialized**: The `ConnectionStage` GraphStage sub-fuses a new Akka.Streams graph for the connection: - Protocol engine decodes transport bytes into IFeatureCollection - ApplicationBridgeStage creates TContext via IHttpApplication.CreateContext() - ASP.NET Core middleware pipeline processes the request - Response features are encoded back to bytes and sent -4. **Request loop**: The connection waits for the next request (keep-alive) or closes -5. **Completion**: When the connection closes (client disconnect, keep-alive timeout, error): - - ConnectionActor reports completion reason to supervisor - - Actor terminates +3. **Request loop**: The connection waits for the next request (keep-alive) or closes +4. **Completion**: When the connection closes (client disconnect, keep-alive timeout, error): + - The sub-graph completes and the active connection count in `ConnectionStage` decrements - Resources are cleaned up ::: tip Keep-Alive Behavior -HTTP/1.1 connections reuse the same ConnectionActor for multiple requests. Each request flows through the pipeline independently, but the TCP/TLS connection and actor stay alive. HTTP/2 and 3 multiplex streams within one connection, all handled by the same actor. +HTTP/1.1 connections reuse the same TCP/TLS connection for multiple requests. Each request flows through the pipeline independently, but the connection and its sub-graph stay alive. HTTP/2 and HTTP/3 multiplex streams within one connection, all handled by the same materialized pipeline. ::: ## Graceful Shutdown @@ -117,8 +109,8 @@ When your application receives a shutdown signal (SIGTERM, Ctrl+C, or explicit ` - Already-connected clients can still send requests 3. **Coordinated Shutdown phase 2 — ServiceUnbind**: - ServerSupervisorActor receives `BeginDrain` message - - All ConnectionActors receive `GracefulStop` with a timeout value - - Each connection cancels its pipeline (sends back `HTTP/1.1 503 Service Unavailable` or TCP RST for HTTP/2) + - The shared drain kill switch on each `ConnectionStage` is triggered + - Each active connection pipeline is cancelled (sends back `HTTP/1.1 503 Service Unavailable` or TCP RST for HTTP/2) - In-flight requests are interrupted 4. **Drain wait**: The application waits for up to `GracefulShutdownTimeout` (default 30 seconds) - Connections finish their active work and close @@ -152,7 +144,7 @@ Key options control server and connection behavior: builder.Host.UseTurboHttp(options => { // Limit concurrent connections (0 = unlimited) - options.MaxConcurrentConnections = 1000; + options.Limits.MaxConcurrentConnections = 1000; // Limit concurrent HTTP/2 streams per connection options.Http2.MaxConcurrentStreams = 100; @@ -168,10 +160,10 @@ builder.Host.UseTurboHttp(options => builder.Host.UseTurboHttp(options => { // Time to wait for the next request on keep-alive connections - options.KeepAliveTimeout = TimeSpan.FromSeconds(120); + options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(130); // Time to wait for request headers (includes TLS handshake) - options.RequestHeadersTimeout = TimeSpan.FromSeconds(30); + options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); // Time to wait for request body to arrive options.BodyConsumptionTimeout = TimeSpan.FromSeconds(30); @@ -186,9 +178,9 @@ builder.Host.UseTurboHttp(options => ```csharp builder.Host.UseTurboHttp(options => { - // Buffer size before reading request body into memory - // Larger uploads are streamed - options.BodyBufferThreshold = 64 * 1024; // 64 KB + // Max request body size buffered fully in memory (HTTP/1.x) + // Larger bodies are exposed as a streaming pipe with backpressure + options.Http1.MaxBufferedRequestBodySize = 64 * 1024; // 64 KB // Chunk size when writing response body options.ResponseBodyChunkSize = 16 * 1024; // 16 KB @@ -210,7 +202,7 @@ builder.Host.UseTurboHttp(options => // HTTP/3 settings options.Http3.MaxHeaderListSize = 8192; - options.Http3.EnableWebTransport = false; + options.Http3.QpackBlockedStreams = 100; }); ``` diff --git a/docs/server/index.md b/docs/server/index.md index 952847a0e..fa060c931 100644 --- a/docs/server/index.md +++ b/docs/server/index.md @@ -57,7 +57,7 @@ TurboHTTP implements these ASP.NET Core feature interfaces per request: | `IHttpRequestBodyDetectionFeature` | Whether the request has a body | | `IHttpResponseTrailersFeature` | HTTP trailer headers | | `IHttpConnectionFeature` | Connection ID, local/remote addresses | -| `ITlsHandshakeFeature` | TLS protocol, cipher suite | +| `ITlsHandshakeFeature` | TLS protocol, cipher suite (TurboHTTP's own interface: `TurboHTTP.Server.Context.Features.ITlsHandshakeFeature`) | | `IHttpRequestLifetimeFeature` | Request abort token | | `IHttpRequestIdentifierFeature` | Unique request identifier | | `IHttpMaxRequestBodySizeFeature` | Request body size limit | diff --git a/docs/server/installation.md b/docs/server/installation.md index b4dd6185c..412c80ded 100644 --- a/docs/server/installation.md +++ b/docs/server/installation.md @@ -120,7 +120,7 @@ builder.Host.UseTurboHttp(options => | Protocol | Value | Transport | Notes | |----------|-------|-----------|-------| -| `Http1` | HTTP/1.1 only | TCP | Maximum compatibility | +| `Http1` | HTTP/1.0 and HTTP/1.1 | TCP | Maximum compatibility | | `Http2` | HTTP/2 only | TCP | Multiplexing, HPACK compression | | `Http1AndHttp2` | Both (default) | TCP | ALPN negotiation selects protocol | | `Http3` | HTTP/3 | QUIC (UDP) | Requires HTTPS | @@ -215,36 +215,27 @@ options.ListenLocalhost(5000, listen => }); ``` -## Configuration from appsettings.json +## Configuration via Code -TurboHTTP reads endpoint configuration from the `TurboHTTP` section: +TurboHTTP is configured imperatively through `UseTurboHttp()`. All endpoint and TLS settings are passed as `Action`: -```json +```csharp +var config = builder.Configuration; + +builder.Host.UseTurboHttp(options => { - "TurboHTTP": { - "Endpoints": { - "Http": { - "Url": "http://localhost:5000" - }, - "Https": { - "Url": "https://localhost:5001", - "Protocols": "Http1AndHttp2", - "Certificate": { - "Path": "certs/server.pfx", - "Password": "changeit" - }, - "SslProtocols": "Tls12, Tls13" - } - }, - "HttpsDefaults": { - "SslProtocols": "Tls13", - "HandshakeTimeout": "00:00:30" - } - } -} + // Read from IConfiguration if desired + var port = config.GetValue("Server:Port") ?? 5000; + + options.ListenLocalhost(port); + options.ListenLocalhost(5001, listen => + { + listen.UseHttps(); + }); +}); ``` -Endpoint names (`Http`, `Https`) are arbitrary — use meaningful names for your setup. +There is no automatic binding from `appsettings.json` — you control which config values feed into `TurboServerOptions` at startup. ## Next Steps diff --git a/docs/server/performance.md b/docs/server/performance.md index 7078cb9f8..d27c6730b 100644 --- a/docs/server/performance.md +++ b/docs/server/performance.md @@ -31,10 +31,10 @@ Higher values improve throughput for clients sending many parallel requests. Low ### Request Body Buffer ```csharp -options.BodyBufferThreshold = 128 * 1024; // 128 KB +options.Http1.MaxBufferedRequestBodySize = 128 * 1024; // 128 KB ``` -Default is 64 KB. Request bodies smaller than this threshold are buffered in memory. Larger bodies stream directly to the application. +Default is 64 KB. HTTP/1.x request bodies up to this size are buffered fully in memory. Larger bodies are exposed to the application as a streaming pipe with backpressure. - **Increase** for APIs that commonly receive medium-sized payloads (64-256 KB) - **Decrease** for memory-constrained environments or very large upload workloads diff --git a/docs/superpowers/plans/2026-05-27-iserver-pipeline-redesign.md b/docs/superpowers/plans/2026-05-27-iserver-pipeline-redesign.md deleted file mode 100644 index 6a15f28d1..000000000 --- a/docs/superpowers/plans/2026-05-27-iserver-pipeline-redesign.md +++ /dev/null @@ -1,1456 +0,0 @@ -# IServer Pipeline Redesign — 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:** Strip TurboHTTP to a pure transport+protocol layer where `IFeatureCollection` is the stream element, `ApplicationBridgeStage` bridges directly to ASP.NET's `IHttpApplication`, and all custom routing/context types are deleted. - -**Architecture:** The Akka Streams pipeline changes from `Protocol → RequestContext → RoutingStage → TurboHttpContext → Handler` to `Protocol → IFeatureCollection → ApplicationBridgeStage → IFeatureCollection → Response Encoder`. Everything between protocol decoding and ASP.NET's `IHttpApplication` is a single generic stage. No wrappers, no custom routing, no custom context types. - -**Tech Stack:** C# 13, .NET 10, Akka.NET Streams, ASP.NET Core `IServer`/`IHttpApplication`, xUnit v3 - -**Spec:** `docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md` - ---- - -## File Map - -### Files to Create -- `src/TurboHTTP/Server/FeatureCollectionFactory.cs` — Pooled factory replacing ServerContextFactory - -### Files to Rewrite -- `src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs` — Full rewrite as `ApplicationBridgeStage` - -### Files to Modify (Production) -| File | Change | -|------|--------| -| `src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs` | Ports: `RequestContext` → `IFeatureCollection` | -| `src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs` | `OnRequest(IFeatureCollection)`, remove `TurboConnectionInfo` | -| `src/TurboHTTP/Protocol/IServerStateMachine.cs` | `OnResponse(IFeatureCollection)` | -| `src/TurboHTTP/Streams/IServerProtocolEngine.cs` | BidiFlow generic args → `IFeatureCollection` | -| `src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs` | Queue/port types → `IFeatureCollection` | -| `src/TurboHTTP/Context/Features/TurboHttpRequestLifetimeFeature.cs` | Self-contained CTS, no RequestContext dep | -| `src/TurboHTTP/Context/Features/TurboHttpRequestIdentifierFeature.cs` | Self-contained TraceIdentifier, no RequestContext dep | -| `src/TurboHTTP/Context/Features/TurboHttpConnectionFeature.cs` | Remove TurboConnectionInfo dep, use fields directly | -| `src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory | -| `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory | -| `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs` | `Encode(Span, IFeatureCollection, ...)` | -| `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs` | `OnResponse(IFeatureCollection)` | -| `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory | -| `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs` | `EncodeHeaders(IFeatureCollection, ...)` | -| `src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs` | `IFeatureCollection` instead of `RequestContext` | -| `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs` | `OnResponse(IFeatureCollection)` | -| `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory | -| `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs` | `EncodeHeaders(IFeatureCollection)` | -| `src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs` | `OnResponse(IFeatureCollection)` | -| `src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs` | Remove RequestContext using if present | -| `src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` | -| `src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` | -| `src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` | -| `src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` | -| `src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs` | Shape port casts → `IFeatureCollection` | -| `src/TurboHTTP/Streams/Http10ServerEngine.cs` | BidiFlow type args | -| `src/TurboHTTP/Streams/Http11ServerEngine.cs` | BidiFlow type args | -| `src/TurboHTTP/Streams/Http20ServerEngine.cs` | BidiFlow type args | -| `src/TurboHTTP/Streams/Http30ServerEngine.cs` | BidiFlow type args | -| `src/TurboHTTP/Streams/NegotiatingServerEngine.cs` | BidiFlow type args | -| `src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs` | Remove `TurboRequestDelegate`/`RouteTable`, add bridge stage flow | -| `src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs` | Remove `TurboRequestDelegate`/`RouteTable`, use bridge stage | -| `src/TurboHTTP/Server/TurboServer.cs` | Wire `IHttpApplication` through to bridge stage | -| `src/TurboHTTP/Server/TurboServerServiceCollectionExtensions.cs` | Remove RouteTable DI | - -### Files to Delete -| File | Reason | -|------|--------| -| `src/TurboHTTP/Streams/Stages/Server/RequestContext.cs` | Replaced by `IFeatureCollection` | -| `src/TurboHTTP/Server/TurboHttpContext.cs` | ASP.NET builds own `HttpContext` | -| `src/TurboHTTP/Context/TurboHttpRequest.cs` | No consumer | -| `src/TurboHTTP/Context/TurboHttpResponse.cs` | No consumer | -| `src/TurboHTTP/Server/TurboConnectionInfo.cs` | ASP.NET uses `IHttpConnectionFeature` | -| `src/TurboHTTP/Streams/Stages/Server/RoutingStage.cs` | No custom routing | -| `src/TurboHTTP/Server/RouteTable.cs` | No custom routing | -| `src/TurboHTTP/Server/TurboRequestDelegate.cs` | No custom pipeline | -| `src/TurboHTTP/Server/ServerContextFactory.cs` | Replaced by FeatureCollectionFactory | - -### Test Files to Modify -| File | Change | -|------|--------| -| `src/TurboHTTP.Tests.Shared/FakeServerOps.cs` | `OnRequest(IFeatureCollection)`, `List` | -| `src/TurboHTTP.Tests.Shared/ServerTestContext.cs` | Return `IFeatureCollection` instead of `RequestContext` | -| `src/TurboHTTP.Tests.Shared/ServerTestContextBuilder.cs` | `Build()` returns `IFeatureCollection` | -| `src/TurboHTTP.Tests/Server/ServerContextFactorySpec.cs` | Rename + test FeatureCollectionFactory | -| `src/TurboHTTP.Tests/Server/ContextPoolingSpec.cs` | Test IFeatureCollection pooling | -| All state machine specs (~15 files) | Use `IFeatureCollection` for OnResponse calls | - ---- - -## Task 1: Self-Contained Feature Implementations - -Remove `RequestContext` dependency from the two feature types that delegate to it. After this task these features own their own state. - -**Files:** -- Modify: `src/TurboHTTP/Context/Features/TurboHttpRequestLifetimeFeature.cs` -- Modify: `src/TurboHTTP/Context/Features/TurboHttpRequestIdentifierFeature.cs` - -- [ ] **Step 1: Rewrite TurboHttpRequestLifetimeFeature** - -Replace the RequestContext-delegating implementation with self-contained state: - -```csharp -using Microsoft.AspNetCore.Http.Features; - -namespace TurboHTTP.Context.Features; - -internal sealed class TurboHttpRequestLifetimeFeature : IHttpRequestLifetimeFeature -{ - public CancellationToken RequestAborted { get; set; } - - public void Abort() => RequestAborted = new CancellationToken(true); -} -``` - -- [ ] **Step 2: Rewrite TurboHttpRequestIdentifierFeature** - -Replace the RequestContext-delegating implementation with self-contained state: - -```csharp -using Microsoft.AspNetCore.Http.Features; - -namespace TurboHTTP.Context.Features; - -internal sealed class TurboHttpRequestIdentifierFeature : IHttpRequestIdentifierFeature -{ - public string TraceIdentifier - { - get => field ??= Guid.NewGuid().ToString("N"); - set; - } -} -``` - -- [ ] **Step 3: Commit** - -``` -git add src/TurboHTTP/Context/Features/TurboHttpRequestLifetimeFeature.cs src/TurboHTTP/Context/Features/TurboHttpRequestIdentifierFeature.cs -git commit -m "refactor: make lifetime and identifier features self-contained" -``` - ---- - -## Task 2: FeatureCollectionFactory - -Replace `ServerContextFactory` (which returns `RequestContext`) with `FeatureCollectionFactory` (returns `IFeatureCollection`). The factory uses the same thread-static pooling pattern. - -**Files:** -- Create: `src/TurboHTTP/Server/FeatureCollectionFactory.cs` -- Modify: `src/TurboHTTP/Context/Features/TurboHttpConnectionFeature.cs` (check if it depends on TurboConnectionInfo constructor) - -- [ ] **Step 1: Read TurboHttpConnectionFeature to check its constructor** - -Check what TurboHttpConnectionFeature needs — it currently takes a `TurboConnectionInfo`. We need to understand if we change this now or later. - -Run: Grep for `class TurboHttpConnectionFeature` and its constructor. - -- [ ] **Step 2: Create FeatureCollectionFactory** - -Create the new factory at `src/TurboHTTP/Server/FeatureCollectionFactory.cs`: - -```csharp -using Microsoft.AspNetCore.Http.Features; -using TurboHTTP.Context.Features; - -namespace TurboHTTP.Server; - -internal static class FeatureCollectionFactory -{ - [ThreadStatic] - private static Stack? t_pool; - - private const int MaxPoolSize = 32; - - public static IFeatureCollection Create( - TurboHttpRequestFeature requestFeature, - bool hasBody, - IServiceProvider? services = null, - IHttpConnectionFeature? connectionFeature = null, - TlsHandshakeFeature? tlsFeature = null) - { - TurboFeatureCollection features; - - if ((t_pool?.Count ?? 0) > 0) - { - features = t_pool!.Pop(); - } - else - { - features = new TurboFeatureCollection(); - } - - features.Set(requestFeature); - - var bodyFeature = new TurboRequestBodyFeature { Body = requestFeature.Body }; - features.Set(bodyFeature); - - var responseFeature = new TurboHttpResponseFeature(); - features.Set(responseFeature); - - var detectionFeature = new TurboHttpRequestBodyDetectionFeature(hasBody); - features.Set(detectionFeature); - - var responseBodyFeature = new TurboHttpResponseBodyFeature(); - features.Set(responseBodyFeature); - - var trailersFeature = new TurboHttpResponseTrailersFeature(); - features.Set(trailersFeature); - - if (connectionFeature is not null) - { - features.Set(connectionFeature); - } - - if (tlsFeature is not null) - { - features.Set(tlsFeature); - } - - var lifetimeFeature = new TurboHttpRequestLifetimeFeature(); - features.Set(lifetimeFeature); - - var identifierFeature = new TurboHttpRequestIdentifierFeature(); - features.Set(identifierFeature); - - return features; - } - - internal static void Return(IFeatureCollection features) - { - if (features is not TurboFeatureCollection turboFeatures) - { - return; - } - - t_pool ??= new Stack(MaxPoolSize); - - if (t_pool.Count < MaxPoolSize) - { - t_pool.Push(turboFeatures); - } - } -} -``` - -Note: The old `ServerContextFactory` took `TurboConnectionInfo?` and created `TurboHttpConnectionFeature` internally. The new factory takes `IHttpConnectionFeature?` directly — the connection feature is created by `HttpConnectionServerStageLogic` from transport info (Task 5 will update this). - -- [ ] **Step 3: Commit** - -``` -git add src/TurboHTTP/Server/FeatureCollectionFactory.cs -git commit -m "feat: add FeatureCollectionFactory returning IFeatureCollection" -``` - ---- - -## Task 3: Core Interface Changes - -Change all four core interfaces/types to use `IFeatureCollection` instead of `RequestContext`. The codebase will NOT compile after this task until Tasks 4-6 are complete. - -**Files:** -- Modify: `src/TurboHTTP/Protocol/IServerStateMachine.cs` -- Modify: `src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs` -- Modify: `src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs` -- Modify: `src/TurboHTTP/Streams/IServerProtocolEngine.cs` - -- [ ] **Step 1: Update IServerStateMachine** - -```csharp -using Microsoft.AspNetCore.Http.Features; -using Servus.Akka.Transport; - -namespace TurboHTTP.Protocol; - -internal interface IServerStateMachine -{ - bool CanAcceptResponse { get; } - bool ShouldComplete { get; } - int MaxQueuedRequests { get; } - - void PreStart(); - void OnResponse(IFeatureCollection features); - void DecodeClientData(ITransportInbound data); - void OnDownstreamFinished(); - void OnTimerFired(string name); - void OnBodyMessage(object msg); - void Cleanup(); -} -``` - -- [ ] **Step 2: Update IServerStageOperations** - -Remove `TurboConnectionInfo` property (it becomes an `IHttpConnectionFeature` created by the stage logic). Change `OnRequest` parameter: - -```csharp -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Microsoft.AspNetCore.Http.Features; -using Servus.Akka.Transport; -using TurboHTTP.Context.Features; - -namespace TurboHTTP.Streams.Stages.Server; - -internal interface IServerStageOperations -{ - void OnRequest(IFeatureCollection features); - void OnOutbound(ITransportOutbound item); - void OnScheduleTimer(string name, TimeSpan delay); - void OnCancelTimer(string name); - ILoggingAdapter Log { get; } - IActorRef StageActor { get; } - IMaterializer Materializer { get; } - IServiceProvider? Services => null; - IHttpConnectionFeature? ConnectionFeature => null; - TlsHandshakeFeature? TlsHandshakeFeature => null; -} -``` - -- [ ] **Step 3: Update ServerConnectionShape** - -Replace all `RequestContext` port types with `IFeatureCollection`: - -```csharp -using System.Collections.Immutable; -using Akka.Streams; -using Microsoft.AspNetCore.Http.Features; -using Servus.Akka.Transport; - -namespace TurboHTTP.Streams.Stages.Server; - -internal sealed class ServerConnectionShape : Shape -{ - public Inlet InNetwork { get; } - public Outlet OutRequest { get; } - public Inlet InResponse { get; } - public Outlet OutNetwork { get; } - - public ServerConnectionShape( - Inlet inNetwork, - Outlet outRequest, - Inlet inResponse, - Outlet outNetwork) - { - InNetwork = inNetwork; - OutRequest = outRequest; - InResponse = inResponse; - OutNetwork = outNetwork; - } - - public override ImmutableArray Inlets => [InNetwork, InResponse]; - - public override ImmutableArray Outlets => [OutRequest, OutNetwork]; - - public override Shape DeepCopy() - { - return new ServerConnectionShape( - (Inlet)InNetwork.CarbonCopy(), - (Outlet)OutRequest.CarbonCopy(), - (Inlet)InResponse.CarbonCopy(), - (Outlet)OutNetwork.CarbonCopy()); - } - - public override Shape CopyFromPorts(ImmutableArray inlets, ImmutableArray outlets) - { - return new ServerConnectionShape( - (Inlet)inlets[0], - (Outlet)outlets[0], - (Inlet)inlets[1], - (Outlet)outlets[1]); - } -} -``` - -- [ ] **Step 4: Update IServerProtocolEngine** - -```csharp -using Akka; -using Akka.Streams.Dsl; -using Microsoft.AspNetCore.Http.Features; -using Servus.Akka.Transport; - -namespace TurboHTTP.Streams; - -internal interface IServerProtocolEngine -{ - BidiFlow CreateFlow( - IServiceProvider? services = null); -} -``` - -- [ ] **Step 5: Commit** - -``` -git add src/TurboHTTP/Protocol/IServerStateMachine.cs src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs src/TurboHTTP/Streams/IServerProtocolEngine.cs -git commit -m "refactor!: change core interfaces from RequestContext to IFeatureCollection" -``` - ---- - -## Task 4: Protocol Encoder + StreamState Changes - -Update all protocol encoders and H2 StreamState to accept `IFeatureCollection` instead of `RequestContext`. These are mechanical: every encoder already does `context.Features.Get()` — change the parameter name from `context` to `features` and remove the `.Features` indirection. - -**Files:** -- Modify: `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs` - -- [ ] **Step 1: Update Http11ServerEncoder** - -Change `Encode` signature from `RequestContext context` to `IFeatureCollection features`. Replace all `context.Features.Get()` with `features.Get()`. Remove `using TurboHTTP.Streams.Stages.Server;`, add `using Microsoft.AspNetCore.Http.Features;` if not already present. - -The method signature becomes: -```csharp -public int Encode(Span destination, IFeatureCollection features, bool isChunked = false, bool connectionClose = false) -``` - -All internal access changes from `context.Features.Get()` to `features.Get()`. - -- [ ] **Step 2: Update Http2ServerEncoder** - -Change `EncodeHeaders` signature: -```csharp -public IReadOnlyList EncodeHeaders(IFeatureCollection features, int streamId, bool hasBody) -``` - -Change `BuildHeaderList` signature: -```csharp -private static void BuildHeaderList(IFeatureCollection features, List headers) -``` - -Replace all `context.Features.Get()` → `features.Get()`. - -- [ ] **Step 3: Update Http3ServerEncoder** - -Change `EncodeHeaders` signature: -```csharp -public HeadersFrame EncodeHeaders(IFeatureCollection features) -``` - -Change `BuildHeaderList` signature: -```csharp -private static void BuildHeaderList(IFeatureCollection features, List<(string Name, string Value)> headers) -``` - -Replace all `context.Features.Get()` → `features.Get()`. - -- [ ] **Step 4: Update Http2 StreamState** - -Replace the `RequestContext` field and methods: -- Change `private RequestContext? _requestContext;` to `private IFeatureCollection? _features;` -- Change `SetTurboContext(RequestContext context)` to `SetFeatures(IFeatureCollection features)` → `_features = features;` -- Change `GetTurboContext()` to `GetFeatures()` → `return _features;` -- Remove `using TurboHTTP.Streams.Stages.Server;`, add `using Microsoft.AspNetCore.Http.Features;` - -- [ ] **Step 5: Commit** - -``` -git add src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs -git commit -m "refactor: update protocol encoders to accept IFeatureCollection" -``` - ---- - -## Task 5: Protocol State Machines + Session Managers - -Update all `OnResponse` implementations and request-creation paths to use `IFeatureCollection` and `FeatureCollectionFactory`. - -**Files:** -- Modify: `src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs` -- Modify: `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs` -- Modify: `src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs` - -- [ ] **Step 1: Update Http10ServerStateMachine** - -Two changes: -1. Request creation (in `DecodeClientData`): Replace `ServerContextFactory.Create(feature, hasBody, ...)` with `FeatureCollectionFactory.Create(feature, hasBody, ...)`. Change `_ops.OnRequest(context)` to `_ops.OnRequest(features)` (variable rename). -2. `OnResponse`: Change signature to `public void OnResponse(IFeatureCollection features)`. Replace `context.Features.Get()` → `features.Get()`. - -Update using: replace `using TurboHTTP.Streams.Stages.Server;` with nothing (no longer needed for RequestContext). Add `using Microsoft.AspNetCore.Http.Features;` if missing. Keep `using TurboHTTP.Server;` for FeatureCollectionFactory. - -- [ ] **Step 2: Update Http11ServerStateMachine** - -Same two changes: -1. Request creation (~line 139): `ServerContextFactory.Create(...)` → `FeatureCollectionFactory.Create(...)`, variable `context` → `features`. -2. `OnResponse` (~line 167): Change parameter to `IFeatureCollection features`. Replace all `context.Features.Get()` → `features.Get()`. The encoder call changes from `_encoder.Encode(span, context, ...)` to `_encoder.Encode(span, features, ...)`. - -- [ ] **Step 3: Update Http2ServerStateMachine + SessionManager** - -StateMachine: `public void OnResponse(IFeatureCollection features) => _sessionManager.OnResponse(features);` - -SessionManager `OnResponse` (~line 129): Change parameter to `IFeatureCollection features`. Replace `context.Features.Get()` → `features.Get()`. The `GetStreamIdFromContext(context)` call needs to change to `GetStreamIdFromFeatures(features)` (or inline: `features.Get()?.StreamId ?? -1`). - -SessionManager request creation (~line 541): Replace `ServerContextFactory.Create(...)` → `FeatureCollectionFactory.Create(...)`. Change `context.Features.Set(...)` → `features.Set(...)`. The StreamState call `state.SetTurboContext(context)` → `state.SetFeatures(features)`. - -The encoder call `_responseEncoder.EncodeHeaders(context, streamId, hasBody)` → `_responseEncoder.EncodeHeaders(features, streamId, hasBody)`. - -- [ ] **Step 4: Update Http3ServerStateMachine + SessionManager** - -Same pattern as H2: - -StateMachine: `public void OnResponse(IFeatureCollection features) => _sessionManager.OnResponse(features);` - -SessionManager: Same changes as H2 — parameter rename, `FeatureCollectionFactory.Create()`, `features.Get/Set`, encoder accepts `IFeatureCollection`. - -- [ ] **Step 5: Update ProtocolNegotiatingStateMachine** - -```csharp -public void OnResponse(IFeatureCollection features) => _inner!.OnResponse(features); -``` - -Remove `using TurboHTTP.Streams.Stages.Server;` if no longer needed. - -- [ ] **Step 6: Commit** - -``` -git add src/TurboHTTP/Protocol/ -git commit -m "refactor: update all state machines and session managers to IFeatureCollection" -``` - ---- - -## Task 6: HttpConnectionServerStageLogic + Connection Stages + Engines - -Update the core stage logic, all five connection stages, and all five engine classes. The connection stages and engines are mostly mechanical port-type changes. - -**Files:** -- Modify: `src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs` -- Modify: `src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs` -- Modify: `src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs` -- Modify: `src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs` -- Modify: `src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs` -- Modify: `src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs` -- Modify: `src/TurboHTTP/Streams/Http10ServerEngine.cs` -- Modify: `src/TurboHTTP/Streams/Http11ServerEngine.cs` -- Modify: `src/TurboHTTP/Streams/Http20ServerEngine.cs` -- Modify: `src/TurboHTTP/Streams/Http30ServerEngine.cs` -- Modify: `src/TurboHTTP/Streams/NegotiatingServerEngine.cs` - -- [ ] **Step 1: Update HttpConnectionServerStageLogic** - -Key changes: -1. Field types: `Outlet` → `Outlet`, `Inlet` → `Inlet`, `Queue` → `Queue` -2. `OnRequest(IFeatureCollection features)` implementation: same logic, different type -3. Response handler (`_inResponse` onPush): `var response = Grab(_inResponse);` now gives `IFeatureCollection`. `_sm.OnResponse(response)` already matches new interface. `response.Features.Get()` → `response.Get()` (no `.Features` needed). `ServerContextFactory.Return(response)` → `FeatureCollectionFactory.Return(response)`. -4. Connection info: The `_connectionInfo` field changes from `TurboConnectionInfo?` to `TurboHttpConnectionFeature?`. In `OnNetworkPush` where `TransportConnected` is handled, create `TurboHttpConnectionFeature` directly instead of `TurboConnectionInfo`. -5. Remove `using TurboHTTP.Server;` for TurboConnectionInfo. Add `using Microsoft.AspNetCore.Http.Features;`. - -The `IServerStageOperations.ConnectionInfo` property changes from `TurboConnectionInfo?` to `IHttpConnectionFeature?` — return `_connectionFeature`. - -- [ ] **Step 2: Update all five connection stages** - -Each connection stage defines four ports with explicit types. Update port declarations: - -```csharp -// Before -private readonly Outlet _outRequest = new("Http11.Request.Out"); -private readonly Inlet _inResponse = new("Http11.Response.In"); - -// After -private readonly Outlet _outRequest = new("Http11.Request.Out"); -private readonly Inlet _inResponse = new("Http11.Response.In"); -``` - -Add `using Microsoft.AspNetCore.Http.Features;`, remove `using TurboHTTP.Streams.Stages.Server;` if RequestContext was the only reason. - -Apply to: `Http10ServerConnectionStage`, `Http11ServerConnectionStage`, `Http20ServerConnectionStage`, `Http30ServerConnectionStage`, `ProtocolNegotiatorConnectionStage`. - -- [ ] **Step 3: Update all five engine classes** - -Each engine's `CreateFlow` method returns a `BidiFlow`. Change to `BidiFlow`. - -Apply to: `Http10ServerEngine`, `Http11ServerEngine`, `Http20ServerEngine`, `Http30ServerEngine`, `NegotiatingServerEngine`. - -Add `using Microsoft.AspNetCore.Http.Features;`, remove `using TurboHTTP.Streams.Stages.Server;` if no longer needed. - -- [ ] **Step 4: Commit** - -``` -git add src/TurboHTTP/Streams/ -git commit -m "refactor: update stage logic, connection stages, and engines to IFeatureCollection" -``` - ---- - -## Task 7: Rewrite ApplicationBridgeStage\ - -Full rewrite of ApplicationBridgeStage as a generic stage that directly holds `IHttpApplication`. Shape changes to `FlowShape`. - -**Files:** -- Rewrite: `src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs` - -- [ ] **Step 1: Write the new ApplicationBridgeStage\** - -```csharp -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Stage; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Http.Features; -using TurboHTTP.Context.Features; - -namespace TurboHTTP.Streams.Stages.Server; - -internal sealed class ApplicationBridgeStage : GraphStage> - where TContext : notnull -{ - private readonly IHttpApplication _application; - private readonly int _parallelism; - private readonly TimeSpan _handlerTimeout; - private readonly TimeSpan _handlerGracePeriod; - - private readonly Inlet _in = new("AppBridge.In"); - private readonly Outlet _out = new("AppBridge.Out"); - - public override FlowShape Shape { get; } - - public ApplicationBridgeStage( - IHttpApplication application, - int parallelism, - TimeSpan handlerTimeout, - TimeSpan handlerGracePeriod) - { - _application = application; - _parallelism = parallelism; - _handlerTimeout = handlerTimeout; - _handlerGracePeriod = handlerGracePeriod; - Shape = new FlowShape(_in, _out); - } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new Logic(this); - - private sealed record DispatchCompleted(int Sequence, IFeatureCollection Features); - - private sealed record DispatchFailed(int Sequence, IFeatureCollection Features, Exception Error); - - private sealed record ResponseReady(int Sequence, IFeatureCollection Features, Task HandlerTask); - - private sealed record HandlerFinished(int Sequence, IFeatureCollection Features); - - private sealed record HandlerFaulted(int Sequence, IFeatureCollection Features, Exception Error); - - private sealed record HandlerTimedOut(int Sequence, IFeatureCollection Features); - - private sealed class Logic : GraphStageLogic - { - private readonly ApplicationBridgeStage _stage; - private IActorRef? _stageActor; - private bool _upstreamFinished; - private int _inFlight; - private int _sequence; - private int _nextToEmit; - private bool _downstreamReady; - private readonly SortedDictionary _pending = []; - private readonly Dictionary _activeTimeouts = []; - private readonly Dictionary _appContexts = []; - - public Logic(ApplicationBridgeStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage._in, - onPush: OnPush, - onUpstreamFinish: () => - { - _upstreamFinished = true; - if (_inFlight == 0) - { - CompleteStage(); - } - }); - - SetHandler(stage._out, - onPull: () => - { - _downstreamReady = true; - TryEmitPending(); - TryPullNext(); - }); - } - - public override void PreStart() - { - _stageActor = GetStageActor(OnMessage).Ref; - Pull(_stage._in); - } - - private void OnPush() - { - var features = Grab(_stage._in); - var seq = _sequence++; - - _inFlight++; - - try - { - DispatchAsync(features, seq); - } - catch (Exception) - { - _inFlight--; - var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } - CompleteResponseBody(features); - Emit(seq, features); - } - - TryPullNext(); - } - - private void DispatchAsync(IFeatureCollection features, int seq) - { - TContext appContext; - try - { - appContext = _stage._application.CreateContext(features); - _appContexts[seq] = appContext; - } - catch (Exception) - { - _inFlight--; - var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } - CompleteResponseBody(features); - Emit(seq, features); - return; - } - - var task = _stage._application.ProcessRequestAsync(appContext); - - if (task.IsCompletedSuccessfully) - { - _inFlight--; - _stage._application.DisposeContext(appContext, null); - _appContexts.Remove(seq); - CompleteResponseBody(features); - Emit(seq, features); - } - else if (task.IsFaulted) - { - _inFlight--; - var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } - _stage._application.DisposeContext(appContext, task.Exception); - _appContexts.Remove(seq); - CompleteResponseBody(features); - Emit(seq, features); - } - else - { - var lifetime = features.Get(); - var cts = lifetime is not null - ? CancellationTokenSource.CreateLinkedTokenSource(lifetime.RequestAborted) - : new CancellationTokenSource(); - cts.CancelAfter(_stage._handlerTimeout); - _activeTimeouts[seq] = cts; - - var bodyFeature = features.Get() as TurboHttpResponseBodyFeature; - var headersReady = bodyFeature?.WhenHeadersReady; - - Task.Delay(_stage._handlerTimeout + _stage._handlerGracePeriod, cts.Token) - .PipeTo(_stageActor!, - success: () => new HandlerTimedOut(seq, features)); - - if (headersReady is not null) - { - Task.WhenAny(headersReady, task) - .PipeTo(_stageActor!, - success: () => new ResponseReady(seq, features, task)); - } - else - { - task.PipeTo(_stageActor!, - success: () => new DispatchCompleted(seq, features), - failure: ex => new DispatchFailed(seq, features, ex)); - } - } - } - - private void OnMessage((IActorRef sender, object msg) args) - { - switch (args.msg) - { - case ResponseReady(var seq, var features, var handlerTask): - if (handlerTask.IsFaulted) - { - if (features.Get() is not TurboHttpResponseBodyFeature - { - HasStarted: true - }) - { - var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } - } - } - - if (handlerTask.IsCompleted) - { - CompleteResponseBody(features); - _inFlight--; - DisposeCts(seq); - DisposeAppContext(seq, handlerTask.Exception); - Emit(seq, features); - } - else - { - Emit(seq, features); - handlerTask.PipeTo(_stageActor!, - success: () => new HandlerFinished(seq, features), - failure: ex => new HandlerFaulted(seq, features, ex)); - } - - break; - - case HandlerFinished(var seq, var finishedFeatures): - CompleteResponseBody(finishedFeatures); - _inFlight--; - DisposeCts(seq); - DisposeAppContext(seq, null); - if (_upstreamFinished && _inFlight == 0) - { - CompleteStage(); - } - - break; - - case HandlerFaulted(var seq, var faultedFeatures, var error): - CompleteResponseBody(faultedFeatures); - _inFlight--; - DisposeCts(seq); - DisposeAppContext(seq, error); - if (_upstreamFinished && _inFlight == 0) - { - CompleteStage(); - } - - break; - - case DispatchCompleted(var seq, var features): - _inFlight--; - DisposeCts(seq); - DisposeAppContext(seq, null); - CompleteResponseBody(features); - Emit(seq, features); - break; - - case DispatchFailed(var seq, var features, var error): - _inFlight--; - DisposeCts(seq); - DisposeAppContext(seq, error); - var respFeature = features.Get(); - if (respFeature is not null) - { - respFeature.StatusCode = 500; - } - CompleteResponseBody(features); - Emit(seq, features); - break; - - case HandlerTimedOut(var seq, var features): - if (_activeTimeouts.TryGetValue(seq, out var cts)) - { - cts.Dispose(); - _activeTimeouts.Remove(seq); - var respFeatureTimeout = features.Get(); - if (respFeatureTimeout is not null && respFeatureTimeout.StatusCode == 200) - { - respFeatureTimeout.StatusCode = 503; - CompleteResponseBody(features); - _inFlight--; - DisposeAppContext(seq, null); - Emit(seq, features); - } - } - - break; - } - - if (_upstreamFinished && _inFlight == 0 && _pending.Count == 0) - { - CompleteStage(); - } - } - - private void DisposeAppContext(int seq, Exception? exception) - { - if (_appContexts.TryGetValue(seq, out var appCtx)) - { - _stage._application.DisposeContext(appCtx, exception); - _appContexts.Remove(seq); - } - } - - private void DisposeCts(int seq) - { - if (_activeTimeouts.TryGetValue(seq, out var cts)) - { - cts.Dispose(); - _activeTimeouts.Remove(seq); - } - } - - private void TryPullNext() - { - if (_inFlight < _stage._parallelism && !HasBeenPulled(_stage._in)) - { - Pull(_stage._in); - } - } - - private void Emit(int seq, IFeatureCollection features) - { - _pending[seq] = features; - TryEmitPending(); - } - - private void TryEmitPending() - { - while (_downstreamReady && _pending.Count > 0 && _pending.Keys.First() == _nextToEmit) - { - _downstreamReady = false; - Push(_stage._out, _pending[_nextToEmit]); - _pending.Remove(_nextToEmit); - _nextToEmit++; - } - } - - private static void CompleteResponseBody(IFeatureCollection features) - { - var bodyFeature = features.Get() as TurboHttpResponseBodyFeature; - bodyFeature?.Complete(); - } - } -} -``` - -Key improvements over old version: -- Generic `` — no type erasure, `_appContexts` is `Dictionary` not `Dictionary` -- `IFeatureCollection` directly — no RequestContext wrapper -- Consolidated `DisposeAppContext` helper — reduces duplication -- Lifetime CTS from `IHttpRequestLifetimeFeature` — no `RequestContext.Lifetime` - -- [ ] **Step 2: Commit** - -``` -git add src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs -git commit -m "refactor!: rewrite ApplicationBridgeStage as generic with IHttpApplication" -``` - ---- - -## Task 8: Actor + Server Integration - -Update `ListenerActor`, `ConnectionActor`, and `TurboServer` to use `ApplicationBridgeStage` instead of `RoutingStage`. The actors receive the bridge flow instead of routing delegate + route table. - -**Files:** -- Modify: `src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs` -- Modify: `src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs` -- Modify: `src/TurboHTTP/Server/TurboServer.cs` - -- [ ] **Step 1: Update ConnectionActor** - -Change the `Materialize` record to receive a `Flow` instead of `TurboRequestDelegate` + `RouteTable`: - -```csharp -public sealed record Materialize( - Flow ConnectionFlow, - IServerProtocolEngine Engine, - Flow BridgeFlow, - IServiceProvider Services, - IMaterializer Materializer, - string? ConnectionLoggingCategory = null); -``` - -In `OnMaterialize`, replace the RoutingStage with the bridge flow: - -```csharp -private void OnMaterialize(Materialize msg) -{ - _log.Debug("Connection {0} materializing pipeline", _connectionId); - - _killSwitch = KillSwitches.Shared("connection-" + _connectionId); - - var protocolBidi = msg.Engine.CreateFlow(msg.Services); - var composed = protocolBidi.Join(msg.BridgeFlow); - - // ... rest of logging and pipeline assembly unchanged ... - - var completionTask = pipeline - .ViaMaterialized( - Flow.Create().WatchTermination(Keep.Right), - Keep.Right) - .Join(composed) - .Run(msg.Materializer); - - completionTask.PipeTo(self, - success: () => new StreamCompleted(null), - failure: ex => new StreamCompleted(ex)); -} -``` - -Remove `using TurboHTTP.Server;` for RouteTable/TurboRequestDelegate. Remove `using TurboHTTP.Streams.Stages.Server;` for RoutingStage. Add `using Microsoft.AspNetCore.Http.Features;`. - -- [ ] **Step 2: Update ListenerActor** - -Remove `TurboRequestDelegate` and `RouteTable` fields/params. Add `Flow` bridge flow: - -Constructor changes: -```csharp -public ListenerActor( - IListenerFactory factory, - ListenerOptions listenerOptions, - TurboServerOptions serverOptions, - Flow bridgeFlow, - IServiceProvider services, - IMaterializer materializer, - string? connectionLoggingCategory = null) -``` - -`Create` factory method: -```csharp -public static Props Create( - IListenerFactory factory, - ListenerOptions listenerOptions, - TurboServerOptions serverOptions, - Flow bridgeFlow, - IServiceProvider services, - IMaterializer materializer, - string? connectionLoggingCategory = null) - => Props.Create(() => new ListenerActor( - factory, listenerOptions, serverOptions, - bridgeFlow, services, materializer, - connectionLoggingCategory)); -``` - -In `OnIncomingConnection`, the `Materialize` message changes: -```csharp -child.Tell(new ConnectionActor.Materialize( - msg.ConnectionFlow, - engine, - _bridgeFlow, - _services, - _materializer, - _connectionLoggingCategory)); -``` - -- [ ] **Step 3: Update TurboServer** - -Wire `IHttpApplication` through to the bridge stage: - -```csharp -public async Task StartAsync( - IHttpApplication application, - CancellationToken cancellationToken) where TContext : notnull -{ - _system = _services.GetService(); - if (_system is null) - { - var setup = BootstrapSetup.Create() - .WithConfig(LoggingHocon) - .And(new LoggerFactorySetup(_loggerFactory)); - _system = ActorSystem.Create("turbo-server", setup); - _ownsSystem = true; - } - - var materializer = _system.Materializer(); - - // Parallelism controls max in-flight requests per connection in the bridge. - // Use H2 MaxConcurrentStreams as a reasonable default (100). - // H1.1 connections are sequential anyway; H2/H3 benefit from parallel dispatch. - var parallelism = _options.Http2.MaxConcurrentStreams; - var bridgeStage = new ApplicationBridgeStage( - application, - parallelism, - _options.HandlerTimeout, - _options.HandlerGracePeriod); - var bridgeFlow = Flow.FromGraph(bridgeStage); - - var resolver = new EndpointResolver(); - var resolvedEndpoints = resolver.Resolve(_options); - - var listenerProps = new List(resolvedEndpoints.Count); - foreach (var endpoint in resolvedEndpoints) - { - listenerProps.Add(ListenerActor.Create( - endpoint.Factory, - endpoint.Options, - _options, - bridgeFlow, - _services, - materializer, - endpoint.ConnectionLoggingCategory)); - } - - // ... rest unchanged (supervisor, coordinated shutdown) ... -} -``` - -Remove the dead-code `TurboRequestDelegate pipeline = _ => Task.CompletedTask;` and `new TurboRouteTable().Freeze()`. - -Note: If `TurboServerOptions.Limits.MaxConcurrentRequests` doesn't exist yet, use a sensible default (e.g., `_options.Http2.MaxConcurrentStreams` or hardcode `100`). Check what property is available. - -- [ ] **Step 4: Commit** - -``` -git add src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs src/TurboHTTP/Server/TurboServer.cs -git commit -m "refactor!: wire IHttpApplication through actors to ApplicationBridgeStage" -``` - ---- - -## Task 9: Delete Old Types + DI Cleanup - -Remove all types that are no longer referenced. Clean up DI registration. - -**Files:** -- Delete: `src/TurboHTTP/Streams/Stages/Server/RequestContext.cs` -- Delete: `src/TurboHTTP/Server/TurboHttpContext.cs` -- Delete: `src/TurboHTTP/Context/TurboHttpRequest.cs` -- Delete: `src/TurboHTTP/Context/TurboHttpResponse.cs` -- Delete: `src/TurboHTTP/Server/TurboConnectionInfo.cs` -- Delete: `src/TurboHTTP/Streams/Stages/Server/RoutingStage.cs` -- Delete: `src/TurboHTTP/Server/RouteTable.cs` -- Delete: `src/TurboHTTP/Server/TurboRequestDelegate.cs` -- Delete: `src/TurboHTTP/Server/ServerContextFactory.cs` -- Modify: `src/TurboHTTP/Server/TurboServerServiceCollectionExtensions.cs` - -- [ ] **Step 1: Delete all obsolete files** - -Delete each file listed above. Use `git rm` or filesystem delete. - -- [ ] **Step 2: Clean up TurboServerServiceCollectionExtensions** - -Remove any `RouteTable`-related registration. The `AddTurboKestrel` methods that registered `TurboRouteTable` should just register `IServer → TurboServer` and options. Check if there's a `TurboRouteTable` singleton registration to remove. - -Looking at the current code, `AddTurboKestrel` doesn't register RouteTable (TurboServer created it inline). No changes needed beyond verifying no compilation errors from deleted types. - -- [ ] **Step 3: Remove stale using directives** - -Grep for `using TurboHTTP.Streams.Stages.Server;` and `using TurboHTTP.Server;` across the codebase and remove any that now reference only deleted types. Key files to check: -- `src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs` — may have unused using for RequestContext - -- [ ] **Step 4: Attempt compilation** - -Run: `dotnet build --configuration Release src/TurboHTTP.slnx` - -Fix any remaining compilation errors from missed references to deleted types. - -- [ ] **Step 5: Commit** - -``` -git add -A -git commit -m "refactor!: delete RequestContext, TurboHttpContext, RoutingStage, and all custom routing types" -``` - ---- - -## Task 10: Test Helper Updates - -Update the shared test helpers to work with `IFeatureCollection` instead of `RequestContext`. - -**Files:** -- Modify: `src/TurboHTTP.Tests.Shared/FakeServerOps.cs` -- Modify: `src/TurboHTTP.Tests.Shared/ServerTestContext.cs` -- Modify: `src/TurboHTTP.Tests.Shared/ServerTestContextBuilder.cs` - -- [ ] **Step 1: Update FakeServerOps** - -```csharp -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Microsoft.AspNetCore.Http.Features; -using Servus.Akka.Transport; -using TurboHTTP.Streams.Stages.Server; - -namespace TurboHTTP.Tests.Shared; - -internal sealed class FakeServerOps : IServerStageOperations -{ - private readonly List _features = []; - - public List Requests => _features; - public List Outbound { get; } = []; - public List<(string Name, TimeSpan Delay)> ScheduledTimers { get; } = []; - public List CancelledTimers { get; } = []; - - public void OnRequest(IFeatureCollection features) => _features.Add(features); - public void OnOutbound(ITransportOutbound item) => Outbound.Add(item); - - public void OnScheduleTimer(string name, TimeSpan delay) - { - ScheduledTimers.RemoveAll(t => t.Name == name); - ScheduledTimers.Add((name, delay)); - } - - public void OnCancelTimer(string name) - { - ScheduledTimers.RemoveAll(t => t.Name == name); - CancelledTimers.Add(name); - } - - public ILoggingAdapter Log => NoLogger.Instance; - public IActorRef StageActor { get; set; } = ActorRefs.Nobody; - public IMaterializer Materializer { get; set; } = null!; -} -``` - -- [ ] **Step 2: Update ServerTestContext** - -```csharp -using Microsoft.AspNetCore.Http.Features; -using TurboHTTP.Context.Features; - -namespace TurboHTTP.Tests.Shared; - -internal static class ServerTestContext -{ - internal static ServerTestContextBuilder Request() => new(); - - internal static IFeatureCollection CreateResponse(int statusCode = 200) - { - var features = new TurboFeatureCollection(); - features.Set(new TurboHttpRequestFeature()); - var responseFeature = new TurboHttpResponseFeature { StatusCode = statusCode }; - features.Set(responseFeature); - var bodyFeature = new TurboHttpResponseBodyFeature(); - features.Set(bodyFeature); - return features; - } - - internal static IFeatureCollection CreateH2Response(int streamId, int statusCode = 200) - { - var features = CreateResponse(statusCode); - features.Set(new TurboStreamIdFeature(streamId)); - return features; - } - - internal static IFeatureCollection CreateH3Response(long streamId, int statusCode = 200) - { - var features = CreateResponse(statusCode); - features.Set(new TurboStreamIdFeature(streamId)); - return features; - } -} -``` - -- [ ] **Step 3: Update ServerTestContextBuilder** - -Change `Build()` to return `IFeatureCollection` instead of `RequestContext`. Remove `TurboConnectionInfo` creation — create `TurboHttpConnectionFeature` directly with the connection data: - -```csharp -public IFeatureCollection Build() -{ - var features = new TurboFeatureCollection(); - var requestFeature = BuildRequestFeature(); - features.Set(requestFeature); - var requestBodyFeature = new TurboRequestBodyFeature - { - Body = requestFeature.Body, - BodySource = _bodySource ?? Source.Empty>() - }; - features.Set(new TurboHttpResponseFeature()); - - if (_connection is not null) - { - features.Set(_connection); - } - - var bodyFeature = new TurboHttpResponseBodyFeature(); - features.Set(bodyFeature); - - var lifetimeFeature = new TurboHttpRequestLifetimeFeature(); - if (_cancellationToken != CancellationToken.None) - { - lifetimeFeature.RequestAborted = _cancellationToken; - } - features.Set(lifetimeFeature); - - return features; -} -``` - -Change `Connection(TurboConnectionInfo)` to `Connection(IHttpConnectionFeature)` — update the field type from `TurboConnectionInfo?` to `IHttpConnectionFeature?`. - -Remove usings for deleted types. - -- [ ] **Step 4: Commit** - -``` -git add src/TurboHTTP.Tests.Shared/ -git commit -m "refactor: update test helpers to use IFeatureCollection" -``` - ---- - -## Task 11: Unit Test Updates - -Update all state machine test specs that call `OnResponse(RequestContext)` or construct `RequestContext`. These tests use `ServerTestContext.CreateResponse()` and `FakeServerOps.Requests` — both now return/hold `IFeatureCollection`. - -**Files:** All state machine spec files in `src/TurboHTTP.Tests/Protocol/` - -- [ ] **Step 1: Bulk-update OnResponse calls in test files** - -The test pattern changes from: -```csharp -var response = ServerTestContext.CreateResponse(200); -sm.OnResponse(response); -``` -to the same code — the return type changed but the call site is identical since `ServerTestContext.CreateResponse()` now returns `IFeatureCollection`. - -The main change needed: where tests access `response.Features.Get()`, change to `response.Get()` (no `.Features` property on `IFeatureCollection`). - -Grep across `src/TurboHTTP.Tests/` for `\.Features\.Get` and `\.Features\.Set` to find all sites that need updating. - -Also grep for `new RequestContext` — these direct constructions need to change to creating `TurboFeatureCollection` directly. - -- [ ] **Step 2: Update ServerContextFactorySpec** - -Rename file to `FeatureCollectionFactorySpec.cs`. Change all `ServerContextFactory.Create(...)` → `FeatureCollectionFactory.Create(...)` and `ServerContextFactory.Return(...)` → `FeatureCollectionFactory.Return(...)`. The return type changes from `RequestContext` to `IFeatureCollection`, so `ctx.Features.Get()` → `features.Get()`. - -- [ ] **Step 3: Update ContextPoolingSpec** - -Same pattern: `ServerContextFactory.Create/Return` → `FeatureCollectionFactory.Create/Return`. Return types are `IFeatureCollection`. - -- [ ] **Step 4: Remove usings for deleted types** - -Grep `src/TurboHTTP.Tests/` for `using TurboHTTP.Streams.Stages.Server;` and remove where the only usage was `RequestContext`. Same for `using TurboHTTP.Server;` where only `TurboConnectionInfo` was used. - -- [ ] **Step 5: Attempt test compilation** - -Run: `dotnet build src/TurboHTTP.Tests/TurboHTTP.Tests.csproj` - -Fix any remaining compilation errors. - -- [ ] **Step 6: Run tests** - -Run: `dotnet run --project src/TurboHTTP.Tests/TurboHTTP.Tests.csproj` - -All existing tests should pass since the behavioral logic is unchanged — only the carrier type changed. - -- [ ] **Step 7: Commit** - -``` -git add src/TurboHTTP.Tests/ -git commit -m "refactor: update all unit tests to use IFeatureCollection" -``` - ---- - -## Task 12: Integration Test + API Surface Cleanup - -Update integration tests (which use RouteTable/TurboRequestDelegate) and the API surface verification test. Integration tests need to be updated to use ASP.NET's `IHttpApplication` pattern or temporarily disabled. - -**Files:** -- Modify: `src/TurboHTTP.IntegrationTests.Server/Shared/ServerSpecBase.cs` -- Modify: Integration test specs that use `ConfigureRoutes` -- Modify: `src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt` -- Modify: `src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ListenerActorConnectionLimitSpec.cs` - -- [ ] **Step 1: Assess integration test scope** - -The integration tests use `ServerSpecBase` which configures routes via `TurboRouteTable`. Since we deleted `RouteTable` and `TurboRequestDelegate`, these tests need to be rewritten to use ASP.NET's `WebApplication` or `IHost` pattern with `TurboServer` as the `IServer`. - -This is a significant rewrite. Read `ServerSpecBase.cs` to understand the current pattern, then decide: rewrite now or mark as `[Fact(Skip = "Pending IServer integration")]`. - -Given the scope, the recommended approach is to **temporarily skip** integration tests with a clear skip reason, then fix them in a follow-up task. The unit tests validate the protocol layer; integration tests validate end-to-end with a real ASP.NET host. - -- [ ] **Step 2: Update ListenerActorConnectionLimitSpec** - -This unit test constructs `ListenerActor` with the old signature. Update to match the new constructor (bridge flow instead of routing delegate + route table). - -- [ ] **Step 3: Update API surface verification** - -The `CoreAPISpec.ApproveCore.DotNet.verified.txt` file lists the public API. Deleted public types (`TurboHttpContext`, `TurboHttpRequest`, `TurboHttpResponse`, `TurboConnectionInfo`, `TurboRequestDelegate`, `RouteTable`) must be removed from the verified file. - -Run: `dotnet run --project src/TurboHTTP.API.Tests/TurboHTTP.API.Tests.csproj` to regenerate the verified file, then approve changes. - -- [ ] **Step 4: Full build + test** - -``` -dotnet build --configuration Release src/TurboHTTP.slnx -dotnet run --project src/TurboHTTP.Tests/TurboHTTP.Tests.csproj -``` - -- [ ] **Step 5: Commit** - -``` -git add -A -git commit -m "refactor: update integration tests and API surface for IServer pipeline" -``` - ---- - -## Task 13: Documentation + CLAUDE.md Update - -Update CLAUDE.md to reflect the new architecture. Remove references to deleted types. - -**Files:** -- Modify: `CLAUDE.md` - -- [ ] **Step 1: Update Architecture section in CLAUDE.md** - -Remove: -- `Context` line referencing `TurboHttpRequest, TurboHttpResponse, Adapters, Features` — change to just `Context (TurboHTTP/Context/) - Features/ (IHttp*Feature implementations), Adapters/` -- `Routing` line — delete entirely - -Remove from Build & Test: -- Any integration test commands that reference deleted features - -Update Code Style if any rules reference `TurboHttpContext`. - -- [ ] **Step 2: Commit** - -``` -git add CLAUDE.md -git commit -m "docs: update CLAUDE.md for IServer pipeline architecture" -``` - ---- - -## Parallelism Map - -Tasks that can run in parallel (after their dependencies complete): - -``` -Task 1 (features) ──┐ - ├── Task 2 (factory) ──┐ -Task 3 (interfaces) ┤ │ - ├── Task 4 (encoders) ─┤ - │ ├── Task 5 (state machines) - ├── Task 6 (stages) ───┤ - │ ├── Task 7 (bridge stage) - │ │ - └──────────────────────┴── Task 8 (actors + server) ── Task 9 (delete) ── Task 10 (test helpers) ── Task 11 (unit tests) ── Task 12 (integration) ── Task 13 (docs) -``` - -**Independent groups after Task 3:** -- Tasks 4+5 (protocol layer) -- Task 6 (stage layer) -- Task 7 (bridge stage) - -These three groups can be dispatched in parallel. Task 8 depends on all three completing. diff --git a/docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md b/docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md deleted file mode 100644 index 5fa759b46..000000000 --- a/docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md +++ /dev/null @@ -1,226 +0,0 @@ -# TurboHTTP IServer Pipeline Redesign - -## Summary - -Strip TurboHTTP down to a pure transport+protocol layer. Remove all custom routing, context types, and middleware abstractions. ASP.NET's `IHttpApplication` becomes the sole request-handling contract. The Akka Streams pipeline carries `IFeatureCollection` directly — no wrapper types. - -## Goals - -- TurboHTTP is a drop-in `IServer` replacement for Kestrel -- ASP.NET Middleware, Controllers, Minimal APIs run natively on TurboHTTP -- No custom routing, no custom context, no custom pipeline delegate -- `IFeatureCollection` is the only data contract between protocol layer and application layer -- Parallel request dispatch with sequence ordering (H2/H3 multiplexing) - -## Non-Goals - -- Custom TurboHTTP routing or middleware system -- TurboHTTP-specific handler APIs -- Standalone server mode (without ASP.NET hosting) - ---- - -## Architecture - -### Stream Element: `IFeatureCollection` - -The Akka Streams pipeline element changes from `RequestContext` to `IFeatureCollection`. - -**Before:** -``` -Protocol Decoder → RequestContext → RoutingStage → TurboHttpContext → Handler -``` - -**After:** -``` -Protocol Decoder → IFeatureCollection → ApplicationBridgeStage → IFeatureCollection → Response Encoder -``` - -No wrapper, no intermediate context type. The feature collection IS the request. - -### ApplicationBridgeStage\ - -Generic Akka `GraphStage>` that bridges Akka Streams to ASP.NET's `IHttpApplication`. - -```csharp -internal sealed class ApplicationBridgeStage - : GraphStage> - where TContext : notnull -{ - private readonly IHttpApplication _application; - private readonly int _parallelism; - private readonly TimeSpan _handlerTimeout; - private readonly TimeSpan _handlerGracePeriod; -} -``` - -**Key properties:** -- Holds `IHttpApplication` directly — no type erasure, no adapter, no `Func` -- The generic parameter is captured in `TurboServer.StartAsync` and flows into the stage -- Stage instance is created once, shared across connection materializations (safe because `IHttpApplication` calls are per-request) - -**Parallel dispatch with sequence ordering:** -- Maintains `_inFlight` counter, pulls up to `_parallelism` concurrent requests -- `SortedDictionary` reorders completed requests for sequential emission -- Each request gets a sequence number on arrival, emitted in order regardless of completion order - -**Timeout management:** -- Per-request `CancellationTokenSource` linked to the request's `IHttpRequestLifetimeFeature` -- `CancelAfter(_handlerTimeout)` on the CTS -- Grace period via `Task.Delay(_handlerTimeout + _handlerGracePeriod)` → `HandlerTimedOut` message -- If timeout fires and no response headers sent: 503 - -**Request lifecycle in the stage:** - -``` -OnPush(features): - seq = _sequence++ - appContext = _application.CreateContext(features) - task = _application.ProcessRequestAsync(appContext) - - if task.IsCompletedSuccessfully: - _application.DisposeContext(appContext, null) - CompleteResponseBody(features) - Emit(seq, features) - - elif task.IsFaulted: - Set 500 on IHttpResponseFeature - _application.DisposeContext(appContext, task.Exception) - CompleteResponseBody(features) - Emit(seq, features) - - else (async): - Start timeout CTS - PipeTo(stageActor) for completion/failure/timeout signals - TryPullNext() for parallel dispatch -``` - -**Estimated size:** ~200-250 lines (down from 387). - -### FeatureCollectionFactory (renamed from ServerContextFactory) - -Pools `TurboFeatureCollection` instances with thread-static stack (max 32). - -```csharp -internal static class FeatureCollectionFactory -{ - public static IFeatureCollection Create( - IHttpRequestFeature requestFeature, - IHttpResponseFeature responseFeature, - IHttpResponseBodyFeature bodyFeature, - IHttpConnectionFeature connectionFeature, - IHttpRequestLifetimeFeature lifetimeFeature, - IHttpRequestIdentifierFeature identifierFeature, - ...); - - public static void Return(IFeatureCollection features); -} -``` - -- `Create()`: pops from pool or allocates, sets all features -- `Return()`: clears all features, disposes CTS from lifetime feature, pushes to pool -- CTS lifecycle: created by the protocol decoder, set as `IHttpRequestLifetimeFeature`, disposed on return - -### TurboServer Changes - -```csharp -public async Task StartAsync( - IHttpApplication application, - CancellationToken cancellationToken) where TContext : notnull -{ - // ActorSystem setup (unchanged) - - var bridgeStage = new ApplicationBridgeStage( - application, - _options.MaxConcurrentRequests, - _options.HandlerTimeout, - _options.HandlerGracePeriod); - - // Resolve endpoints, create listeners with bridgeStage - // No TurboRequestDelegate, no RouteTable -} -``` - -### HttpConnectionServerStageLogic Changes - -Port types change: -- `Outlet` → `Outlet` -- `Inlet` → `Inlet` -- `IServerStageOperations.OnRequest(RequestContext)` → `OnRequest(IFeatureCollection)` - -Protocol decoders create features → `FeatureCollectionFactory.Create(...)` → push `IFeatureCollection` directly. - -### ServerConnectionShape Changes - -The shape definition updates its port types from `RequestContext` to `IFeatureCollection`. - ---- - -## Deletions - -### Types to Delete - -| Type | File | Reason | -|------|------|--------| -| `RequestContext` | `Streams/Stages/Server/RequestContext.cs` | Replaced by `IFeatureCollection` as stream element | -| `TurboHttpContext` | `Server/TurboHttpContext.cs` | ASP.NET builds its own `HttpContext` | -| `TurboHttpRequest` | `Context/TurboHttpRequest.cs` | No consumer without TurboHttpContext | -| `TurboHttpResponse` | `Context/TurboHttpResponse.cs` | No consumer without TurboHttpContext | -| `TurboConnectionInfo` | `Server/TurboConnectionInfo.cs` | ASP.NET has `IHttpConnectionFeature` | -| `RoutingStage` | `Streams/Stages/Server/RoutingStage.cs` | No custom routing | -| `RouteTable` | `Server/RouteTable.cs` | No custom routing | -| `RouteMatchResult` | `Routing/RouteMatchResult.cs` | No custom routing | -| `TurboRequestDelegate` | `Server/TurboRequestDelegate.cs` | No custom pipeline | -| `Routing/` folder | `Routing/**` | All dispatchers, binding, route types | - -### Tests to Delete - -All tests for deleted types: -- `ContextPoolingSpec.cs` (tests RequestContext pooling) -- Any tests for RoutingStage, RouteTable, TurboHttpContext -- Tests for TurboHttpRequest/TurboHttpResponse standalone usage - -### Tests to Modify - -- `ServerContextFactorySpec.cs` → rename to `FeatureCollectionFactorySpec.cs`, test IFeatureCollection pooling -- Integration tests that construct TurboHttpContext manually → use IFeatureCollection directly - ---- - -## DI Registration Changes - -`TurboServerServiceCollectionExtensions.cs`: -- `AddTurboServer()` stays (registers `IServer` → `TurboServer`) -- `AddTurboKestrel()` removes `TurboRouteTable` registration, removes any routing-related DI - ---- - -## Data Flow (Final) - -``` -Network Bytes (TCP/TLS/QUIC) - → TransportFlow (Servus.Akka) - → ProtocolEngine (Http11/H2/H3 decoder) - → FeatureCollectionFactory.Create(requestFeature, responseFeature, ...) - → [IFeatureCollection] pushed to outlet - - → ApplicationBridgeStage - → _application.CreateContext(features) - → _application.ProcessRequestAsync(appContext) - → _application.DisposeContext(appContext, exception) - → CompleteResponseBody(features) - → [IFeatureCollection] emitted downstream - - → HttpConnectionServerStageLogic (response inlet) - → Protocol encoder writes response bytes - → FeatureCollectionFactory.Return(features) - → Network Bytes -``` - ---- - -## Migration Notes - -- The `ApplicationBridgeStage` file gets rewritten, not modified — the current implementation has type-erased `Func` that is replaced by generic `IHttpApplication` -- `ListenerActor` and `ConnectionActor` constructor signatures change (no more `TurboRequestDelegate`/`RouteTable` params, gain `ApplicationBridgeStage` or the graph flow) -- Protocol state machines (`Http11ServerStateMachine`, `Http2ServerSessionManager`, etc.) change their `OnRequest` callback to emit `IFeatureCollection` instead of `RequestContext` diff --git a/lib/servus.akka b/lib/servus.akka new file mode 160000 index 000000000..12dcc14f3 --- /dev/null +++ b/lib/servus.akka @@ -0,0 +1 @@ +Subproject commit 12dcc14f341b6bd269e8cc5f390b87378d3f4eb1 diff --git a/notes/00-Index.md b/notes/00-Index.md index 877f39976..035126e3d 100644 --- a/notes/00-Index.md +++ b/notes/00-Index.md @@ -50,3 +50,11 @@ RFC 9110 (Semantics) RFC 1945 (HTTP/1.0) ──────────── superseded by RFC 9112 RFC 6265 (Cookies) ───────────── extends HTTP semantics ``` + +--- + +## Known Bugs + +| Note | Status | Description | +|------|--------|-------------| +| [[Bugs/H2-response-truncation-race\|H2 Body Truncation Race]] | fixed | QueuedBodyReader cross-thread race lost/reordered body chunks under concurrent streams; fixed 2026-06-11 (lock + async continuations), plus client Content-Length truncation guard | diff --git a/notes/Bugs/H2-response-truncation-race.md b/notes/Bugs/H2-response-truncation-race.md new file mode 100644 index 000000000..bda264541 --- /dev/null +++ b/notes/Bugs/H2-response-truncation-race.md @@ -0,0 +1,68 @@ +--- +status: fixed +component: Protocol/Body +discovered: '2026-06-11' +fixed: '2026-06-11' +branch: fix/stress-benchmarks +severity: high +tags: + - bug + - http2 + - race-condition + - fixed +--- +# H2 Body Truncation/Corruption Race — FIXED (2026-06-11) + +## Root cause + +`QueuedBodyReader` (`src/TurboHttp/Protocol/Body/QueuedBodyReader.cs`) — the ring-buffer +queue between a connection-stage (actor) thread producing body chunks (`TryEnqueue`/`Complete`) +and the application thread consuming them (`ReadAsync`/`AdvanceTo`) — had **no synchronization +at all**. The codebase's "actor confinement makes plain fields safe" convention does not apply +here: this type is a true cross-thread boundary. It is used for HTTP/2 server request bodies, +HTTP/2 client response bodies, HTTP/3 (both sides), and HTTP/1.x streamed bodies — which is why +both directions failed symmetrically. + +The three observed failure modes mapped to specific interleavings: + +| Symptom | Interleaving | +|---------|--------------| +| Whole chunk lost → body short by N×16384, surfaced as HTTP 200 | non-atomic `_count++`/`_count--` race loses an increment; `Complete()` sees `_count == 0` and reports clean end-of-body | +| Adjacent chunks reordered | consumer reads stale `_count == 0`, sets `_readPending`; producer's next chunk is delivered directly via `SetResult`, bypassing the older queued chunk | +| Corrupted payload at correct length | lost decrement → consumer re-reads a stale slot over a returned `ArrayPool` array | + +**It was never a flow-control, frame-encoding, coalescing, or transport bug.** Frame-level +tracing (permanent `DATA in/out` Trace instrumentation added to both session managers) proved +client-out == server-in byte-for-byte; the bytes died between `HandleDataFrame`/`FeedBody` +and the body stream consumer. + +## The fix + +- `QueuedBodyReader`: all mutable state guarded by a private lock; completion delivery + (`SetResult`/`SetException`) claimed atomically (`_readPending` cleared under the lock — + only one of TryEnqueue/Complete/Fault/cancellation/Reset can deliver) and invoked outside + the lock; `ManualResetValueTaskSourceCore.RunContinuationsAsynchronously = true` so consumer + continuations never run on the connection-stage thread; `_core.Reset()` ordered before + `_readPending` publication. +- Secondary fix (client correctness): `StreamState.ExpectedBodyLength` (H2) — END_STREAM + arriving with a byte count != declared Content-Length now faults the body reader with + `HttpRequestException` instead of completing it (skipped for HEAD/204/304). RFC 9113 §8.1.1. + Note: H3 has its own `StreamState`; the same guard is NOT yet wired there. + +## Tests + +- `TurboHttp.Tests/Protocol/Body/QueuedBodyReaderConcurrencySpec.cs` — producer/consumer + hammer with position-derived byte pattern; failed within 1 round pre-fix (lost 39 KB), + green 5×/5× post-fix. +- `TurboHttp.Tests/.../Http2StreamStateBodyTruncationSpec.cs` — 5 specs for the + Content-Length guard (RFC9113-8.1.1 trait). +- `TurboHTTP.IntegrationTests.End2End/H2/PatternedPayloadIntegritySpec.cs` — permanent + regression spec (h2c, 20×512KB, patterned payloads with per-block provenance analysis in + failure messages). + +## Verification + +- Pre-fix: `ConcurrentLargePostSpec` / patterned diagnostic failed ~1 in 5–12 iterations. +- Post-fix: **0 failures in 50+50 iterations** of both repro specs, plus 20 more of the + regression spec; full suites green: unit 5571/5571, End2End 88/88, Server 89/89, + Client 472 (0 failed). diff --git a/release-please-config.json b/release-please-config.json index e243c740a..76663fea3 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,9 +3,12 @@ "packages": { ".": { "release-type": "simple", + "versioning": "prerelease", "bump-minor-pre-major": false, "bump-patch-for-minor-pre-major": false, "include-component-in-tag": false, + "prerelease": true, + "prerelease-type": "alpha", "changelog-sections": [ { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8fc1d88c0..0d9b74aff 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -3,21 +3,23 @@ true true - + - - - + + + + + @@ -28,7 +30,4 @@ - - - \ No newline at end of file diff --git a/src/Servus.Akka.AspNetCore.Tests/AkkaServerSentEventResultSpec.cs b/src/Servus.Akka.AspNetCore.Tests/AkkaServerSentEventResultSpec.cs index ced0f78de..206262a54 100644 --- a/src/Servus.Akka.AspNetCore.Tests/AkkaServerSentEventResultSpec.cs +++ b/src/Servus.Akka.AspNetCore.Tests/AkkaServerSentEventResultSpec.cs @@ -1,4 +1,4 @@ -using System.Text; +using static System.Text.Encoding; using Akka.Actor; using Akka.Streams; using Akka.Streams.Dsl; @@ -64,7 +64,7 @@ public async Task Sse_should_format_single_event() await result.ExecuteAsync(ctx); body.Position = 0; - var content = Encoding.UTF8.GetString(body.ToArray()); + var content = UTF8.GetString(body.ToArray()); Assert.Equal("data: hello\n\n", content); } @@ -83,7 +83,7 @@ public async Task Sse_should_format_multiple_events() await result.ExecuteAsync(ctx); body.Position = 0; - var content = Encoding.UTF8.GetString(body.ToArray()); + var content = UTF8.GetString(body.ToArray()); Assert.Equal("data: first\n\ndata: second\n\n", content); } @@ -99,7 +99,7 @@ public async Task Sse_should_format_event_with_type_and_id() await result.ExecuteAsync(ctx); body.Position = 0; - var content = Encoding.UTF8.GetString(body.ToArray()); + var content = UTF8.GetString(body.ToArray()); Assert.Contains("event: update\n", content); Assert.Contains("data: payload\n", content); Assert.Contains("id: 42\n", content); diff --git a/src/Servus.Akka.AspNetCore.Tests/AkkaStreamResultSpec.cs b/src/Servus.Akka.AspNetCore.Tests/AkkaStreamResultSpec.cs index 1f35d3715..4c8a1d9cc 100644 --- a/src/Servus.Akka.AspNetCore.Tests/AkkaStreamResultSpec.cs +++ b/src/Servus.Akka.AspNetCore.Tests/AkkaStreamResultSpec.cs @@ -1,4 +1,3 @@ -using System.Text; using Akka.Actor; using Akka.Streams; using Akka.Streams.Dsl; @@ -44,7 +43,7 @@ public async Task Stream_should_write_all_chunks_to_response_body() await result.ExecuteAsync(ctx); body.Position = 0; - var content = Encoding.UTF8.GetString(body.ToArray()); + var content = System.Text.Encoding.UTF8.GetString(body.ToArray()); Assert.Equal("hello world", content); } diff --git a/src/Servus.Akka.AspNetCore.Tests/EntityBuilderSpec.cs b/src/Servus.Akka.AspNetCore.Tests/EntityBuilderSpec.cs index a4f5d3523..09323081d 100644 --- a/src/Servus.Akka.AspNetCore.Tests/EntityBuilderSpec.cs +++ b/src/Servus.Akka.AspNetCore.Tests/EntityBuilderSpec.cs @@ -1,7 +1,4 @@ using System.Net; -using System.Text; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; namespace Servus.Akka.AspNetCore.Tests; @@ -100,7 +97,7 @@ public void Ask_should_configure_method_as_ask() { ask.Handle(async (ctx, resp) => { - await ctx.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(resp)); + await ctx.Response.Body.WriteAsync(System.Text.Encoding.UTF8.GetBytes(resp)); }); }); @@ -137,7 +134,7 @@ public void Response_should_add_mapper_to_builder() var builder = new EntityBuilder(); builder.Response(async (ctx, resp) => { - await ctx.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(resp)); + await ctx.Response.Body.WriteAsync(System.Text.Encoding.UTF8.GetBytes(resp)); }); Assert.Equal(1, builder.ResponseMappers.Count); @@ -149,7 +146,7 @@ public void Response_should_be_fluent() var builder = new EntityBuilder(); var result = builder.Response(async (ctx, resp) => { - await ctx.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(resp)); + await ctx.Response.Body.WriteAsync(System.Text.Encoding.UTF8.GetBytes(resp)); }); Assert.Same(builder, result); diff --git a/src/Servus.Akka.AspNetCore/AkkaResults.cs b/src/Servus.Akka.AspNetCore/AkkaResults.cs index 4ab6b26c9..0079894a3 100644 --- a/src/Servus.Akka.AspNetCore/AkkaResults.cs +++ b/src/Servus.Akka.AspNetCore/AkkaResults.cs @@ -9,13 +9,8 @@ namespace Servus.Akka.AspNetCore; public static class AkkaResults { public static IResult Stream(Source, NotUsed> source, IMaterializer materializer, - string contentType = "application/octet-stream") - { - return new AkkaStreamResult(source, materializer, contentType); - } + string contentType = "application/octet-stream") => new AkkaStreamResult(source, materializer, contentType); public static IResult ServerSentEvent(Source source, IMaterializer materializer) - { - return new AkkaSseResult(source, materializer); - } + => new AkkaSseResult(source, materializer); } \ No newline at end of file diff --git a/src/Servus.Akka.AspNetCore/EntityBuilder.cs b/src/Servus.Akka.AspNetCore/EntityBuilder.cs index 4d6116be0..6f103a97d 100644 --- a/src/Servus.Akka.AspNetCore/EntityBuilder.cs +++ b/src/Servus.Akka.AspNetCore/EntityBuilder.cs @@ -8,14 +8,13 @@ namespace Servus.Akka.AspNetCore; public sealed class EntityBuilder { private readonly Dictionary _methods = new(StringComparer.OrdinalIgnoreCase); - private readonly EntityResponseMapperCollection _responseMappers = new(); - private TimeSpan _timeout = TimeSpan.FromSeconds(5); - private IEntityActorResolver _resolver = new ServiceProviderActorResolver(_ => ActorRefs.Nobody); internal IReadOnlyDictionary Methods => _methods; - internal EntityResponseMapperCollection ResponseMappers => _responseMappers; - internal TimeSpan Timeout => _timeout; - internal IEntityActorResolver Resolver => _resolver; + internal EntityResponseMapperCollection ResponseMappers { get; } = new(); + + internal TimeSpan Timeout { get; private set; } = TimeSpan.FromSeconds(5); + + internal IEntityActorResolver Resolver { get; private set; } = new ServiceProviderActorResolver(_ => ActorRefs.Nobody); public EntityMethodBuilder OnGet(Delegate messageFactory) => AddMethod("GET", messageFactory); @@ -34,13 +33,13 @@ public EntityMethodBuilder OnPatch(Delegate messageFactory) public EntityBuilder WithTimeout(TimeSpan timeout) { - _timeout = timeout; + Timeout = timeout; return this; } public EntityBuilder UseResolver(IEntityActorResolver resolver) { - _resolver = resolver; + Resolver = resolver; return this; } @@ -53,7 +52,7 @@ public EntityBuilder UseActorRef(Func factory public EntityBuilder Response(Func mapper) { - _responseMappers.Add(mapper); + ResponseMappers.Add(mapper); return this; } diff --git a/src/Servus.Akka.AspNetCore/Servus.Akka.AspNetCore.csproj b/src/Servus.Akka.AspNetCore/Servus.Akka.AspNetCore.csproj index 7991998c7..7f53ab234 100644 --- a/src/Servus.Akka.AspNetCore/Servus.Akka.AspNetCore.csproj +++ b/src/Servus.Akka.AspNetCore/Servus.Akka.AspNetCore.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Servus.Akka.TestKit.Tests/ActivityLogSpec.cs b/src/Servus.Akka.TestKit.Tests/ActivityLogSpec.cs deleted file mode 100644 index a5e73aa82..000000000 --- a/src/Servus.Akka.TestKit.Tests/ActivityLogSpec.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Net; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit.Tests; - -public sealed class ActivityLogSpec -{ - [Fact(Timeout = 5000)] - public void Record_should_add_entry() - { - var log = new ActivityLog(); - var activity = new OutboundReceived(0, new TransportData(new byte[] { 0xAA })); - - log.Record(activity); - - Assert.Single(log.Entries); - Assert.Same(activity, log.Entries[0]); - } - - [Fact(Timeout = 5000)] - public void OfType_should_filter_by_type() - { - var log = new ActivityLog(); - var outbound = new OutboundReceived(0, new TransportData(new byte[] { 0xAA })); - var connectionInfo = new ConnectionInfo( - new IPEndPoint(IPAddress.Loopback, 1000), - new IPEndPoint(IPAddress.Loopback, 2000), - TransportProtocol.Tcp); - var inbound = new InboundPushed(0, new TransportConnected(connectionInfo)); - var handler = new HandlerInvoked("TestHandler", new TransportData(new byte[] { 0xBB })); - - log.Record(outbound); - log.Record(inbound); - log.Record(handler); - log.Record(new StageCompleted()); - - var outboundEntries = log.OfType().ToList(); - Assert.Single(outboundEntries); - Assert.Same(outbound, outboundEntries[0]); - - var inboundEntries = log.OfType().ToList(); - Assert.Single(inboundEntries); - Assert.Same(inbound, inboundEntries[0]); - - var handlerEntries = log.OfType().ToList(); - Assert.Single(handlerEntries); - Assert.Same(handler, handlerEntries[0]); - } - - [Fact(Timeout = 5000)] - public void Clear_should_remove_all_entries() - { - var log = new ActivityLog(); - log.Record(new OutboundReceived(0, new TransportData(new byte[] { 0xAA }))); - var connectionInfo = new ConnectionInfo( - new IPEndPoint(IPAddress.Loopback, 1000), - new IPEndPoint(IPAddress.Loopback, 2000), - TransportProtocol.Tcp); - log.Record(new InboundPushed(0, new TransportConnected(connectionInfo))); - log.Record(new StageCompleted()); - - Assert.Equal(3, log.Entries.Count); - - log.Clear(); - - Assert.Empty(log.Entries); - } - - [Fact(Timeout = 5000)] - public void ListenerConnectionAccepted_should_set_properties() - { - var activity = new ListenerConnectionAccepted(42, true); - - Assert.Equal(42, activity.Index); - Assert.True(activity.FromFactory); - Assert.NotEqual(default, activity.Timestamp); - } - - [Fact(Timeout = 5000)] - public void Activity_Timestamp_should_be_utc() - { - var activity = new OutboundReceived(0, new TransportData(new byte[] { 0xAA })); - - Assert.Equal(DateTimeOffset.UtcNow.Offset, activity.Timestamp.Offset); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.TestKit.Tests/Servus.Akka.TestKit.Tests.csproj b/src/Servus.Akka.TestKit.Tests/Servus.Akka.TestKit.Tests.csproj deleted file mode 100644 index f4554805b..000000000 --- a/src/Servus.Akka.TestKit.Tests/Servus.Akka.TestKit.Tests.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - Exe - true - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/src/Servus.Akka.TestKit.Tests/TestConnectionStageBuilderExtensionsSpec.cs b/src/Servus.Akka.TestKit.Tests/TestConnectionStageBuilderExtensionsSpec.cs deleted file mode 100644 index 0d300f853..000000000 --- a/src/Servus.Akka.TestKit.Tests/TestConnectionStageBuilderExtensionsSpec.cs +++ /dev/null @@ -1,358 +0,0 @@ -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit.Tests; - -public sealed class TestConnectionStageBuilderExtensionsSpec : global::Akka.TestKit.Xunit.TestKit -{ - private readonly IMaterializer _materializer; - - public TestConnectionStageBuilderExtensionsSpec() - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task OnData_should_invoke_handler_on_TransportData() - { - var ct = TestContext.Current.CancellationToken; - var handlerInvoked = false; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnData((_, ctx) => - { - handlerInvoked = true; - ctx.Push(new TransportData(new byte[] { 0xFF })); - }) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new TransportData(new byte[] { 0xAA }) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.True(handlerInvoked, "OnData handler should have been invoked"); - Assert.IsType(inbound[0]); - var response = Assert.IsType(inbound[1]); - Assert.Equal(0xFF, response.Buffer.Span[0]); - } - - [Fact(Timeout = 5000)] - public async Task OnOpenStream_should_invoke_handler_on_OpenStream() - { - var ct = TestContext.Current.CancellationToken; - var handlerInvoked = false; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnOpenStream((open, ctx) => - { - handlerInvoked = true; - ctx.Push(new StreamOpened(open.StreamId, open.Direction)); - }) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new OpenStream(42, StreamDirection.Bidirectional) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.True(handlerInvoked, "OnOpenStream handler should have been invoked"); - Assert.IsType(inbound[0]); - var opened = Assert.IsType(inbound[1]); - Assert.Equal(42L, opened.Id.Value); - } - - [Fact(Timeout = 5000)] - public async Task OnMultiplexedData_should_invoke_handler_on_MultiplexedData() - { - var ct = TestContext.Current.CancellationToken; - var handlerInvoked = new TaskCompletionSource(); - - var buf = TransportBuffer.Rent(2); - buf.FullMemory.Span[0] = 0xAA; - buf.FullMemory.Span[1] = 0xBB; - buf.Length = 2; - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnMultiplexedData((_, _) => - { - handlerInvoked.TrySetResult(); - }) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new MultiplexedData(buf, 7) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), _materializer); - - await handlerInvoked.Task.WaitAsync(ct); - } - - [Fact(Timeout = 5000)] - public async Task OnDisconnect_should_invoke_handler_on_DisconnectTransport() - { - var ct = TestContext.Current.CancellationToken; - var handlerInvoked = false; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnDisconnect((disconnect, ctx) => - { - handlerInvoked = true; - ctx.Push(new TransportDisconnected(disconnect.Reason)); - }) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new DisconnectTransport(DisconnectReason.Timeout) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.True(handlerInvoked, "OnDisconnect handler should have been invoked"); - Assert.IsType(inbound[0]); - var disconnected = Assert.IsType(inbound[1]); - Assert.Equal(DisconnectReason.Timeout, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public async Task AutoStreamOpened_should_respond_with_StreamOpened_for_matching_streamId() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .AutoStreamOpened(42) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new OpenStream(42, StreamDirection.Bidirectional) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var opened = Assert.IsType(inbound[1]); - Assert.Equal(42L, (long)opened.Id); - Assert.Equal(StreamDirection.Bidirectional, opened.Direction); - } - - [Fact(Timeout = 5000)] - public async Task AutoStreamOpened_should_not_respond_for_different_streamId() - { - var inbound = new List(); - var tcs = new TaskCompletionSource(); - var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .AutoStreamOpened(42) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new OpenStream(99, StreamDirection.Bidirectional) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - // Wait for either the timeout or a second message (which shouldn't come) - try - { - await tcs.Task.WaitAsync(timeout.Token); - } - catch (OperationCanceledException) - { - // Expected: timeout after waiting for a second message that won't arrive - } - - // Should only have TransportConnected, no StreamOpened response - Assert.Single(inbound); - Assert.IsType(inbound[0]); - } - - [Fact(Timeout = 5000)] - public async Task EchoMultiplexedData_should_echo_back_data() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .EchoMultiplexedData() - .Build(); - - var originalData = new byte[] { 0x11, 0x22, 0x33 }; - var originalBuf = TransportBuffer.Rent(originalData.Length); - originalData.CopyTo(originalBuf.FullMemory.Span); - originalBuf.Length = originalData.Length; - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new MultiplexedData(originalBuf, 7) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var echo = Assert.IsType(inbound[1]); - Assert.Equal(7L, (long)echo.StreamId); - Assert.Equal(3, echo.Buffer.Length); - Assert.Equal(0x11, echo.Buffer.Span[0]); - Assert.Equal(0x22, echo.Buffer.Span[1]); - Assert.Equal(0x33, echo.Buffer.Span[2]); - } - - [Fact(Timeout = 5000)] - public async Task OnCompleteWrites_should_invoke_handler_on_CompleteWrites() - { - var ct = TestContext.Current.CancellationToken; - var handlerInvoked = false; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnCompleteWrites((_, ctx) => - { - handlerInvoked = true; - ctx.Push(new TransportDisconnected(DisconnectReason.Graceful)); - }) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new CompleteWrites(0) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.True(handlerInvoked, "OnCompleteWrites handler should have been invoked"); - Assert.IsType(inbound[0]); - var disconnected = Assert.IsType(inbound[1]); - Assert.Equal(DisconnectReason.Graceful, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public async Task OnResetStream_should_invoke_handler_on_ResetStream() - { - var ct = TestContext.Current.CancellationToken; - var handlerInvoked = false; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnResetStream((reset, ctx) => - { - handlerInvoked = true; - ctx.Push(new StreamClosed(reset.StreamId, DisconnectReason.Error)); - }) - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new ResetStream(99) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.True(handlerInvoked, "OnResetStream handler should have been invoked"); - Assert.IsType(inbound[0]); - var closed = Assert.IsType(inbound[1]); - Assert.Equal(99L, (long)closed.Id); - Assert.Equal(DisconnectReason.Error, closed.Reason); - } -} diff --git a/src/Servus.Akka.TestKit.Tests/TestConnectionStageExtensionsSpec.cs b/src/Servus.Akka.TestKit.Tests/TestConnectionStageExtensionsSpec.cs deleted file mode 100644 index 804886ae6..000000000 --- a/src/Servus.Akka.TestKit.Tests/TestConnectionStageExtensionsSpec.cs +++ /dev/null @@ -1,448 +0,0 @@ -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit.Tests; - -public sealed class TestConnectionStageExtensionsSpec : global::Akka.TestKit.Xunit.TestKit -{ - private readonly IMaterializer _materializer; - - public TestConnectionStageExtensionsSpec() - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task PushData_bytes_should_deliver_TransportData_inbound() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushData([1, 2, 3]); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var data = Assert.IsType(inbound[1]); - Assert.Equal(3, data.Buffer.Length); - } - - [Fact(Timeout = 5000)] - public async Task PushData_string_should_deliver_TransportData_inbound() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushData("hello"); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - Assert.IsType(inbound[1]); - } - - [Fact(Timeout = 5000)] - public async Task PushStreamOpened_should_deliver_StreamOpened_inbound() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushStreamOpened(42); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var opened = Assert.IsType(inbound[1]); - Assert.Equal(42L, (long)opened.Id); - Assert.Equal(StreamDirection.Bidirectional, opened.Direction); - } - - [Fact(Timeout = 5000)] - public async Task PushMultiplexedData_should_deliver_MultiplexedData_inbound() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushMultiplexedData(7, [0xAA, 0xBB]); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var mux = Assert.IsType(inbound[1]); - Assert.Equal(7L, (long)mux.StreamId); - Assert.Equal(2, mux.Buffer.Length); - } - - [Fact(Timeout = 5000)] - public async Task SimulateInboundStream_should_push_full_lifecycle() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 5) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.SimulateInboundStream(5, StreamDirection.Unidirectional, [1, 2], [3, 4]); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var accepted = Assert.IsType(inbound[1]); - Assert.Equal(5L, (long)accepted.Id); - Assert.Equal(StreamDirection.Unidirectional, accepted.Direction); - Assert.IsType(inbound[2]); - Assert.IsType(inbound[3]); - Assert.IsType(inbound[4]); - } - - [Fact(Timeout = 5000)] - public async Task PushDisconnected_should_push_TransportDisconnected() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushDisconnected(DisconnectReason.Timeout); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var disconnected = Assert.IsType(inbound[1]); - Assert.Equal(DisconnectReason.Timeout, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public async Task WaitForDataAsync_should_skip_non_data_messages() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new TransportData(new byte[] { 0xAA }) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), _materializer); - - var data = await stage.WaitForDataAsync(ct); - Assert.Equal(0xAA, data.Buffer.Span[0]); - } - - [Fact(Timeout = 5000)] - public async Task WaitForOpenStreamAsync_should_skip_non_open_messages() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new OpenStream(1, StreamDirection.Bidirectional) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), _materializer); - - var open = await stage.WaitForOpenStreamAsync(ct); - Assert.Equal(1L, (long)open.StreamId); - Assert.Equal(StreamDirection.Bidirectional, open.Direction); - } - - [Fact(Timeout = 5000)] - public async Task PushStreamClosed_should_deliver_StreamClosed_inbound() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushStreamClosed(99, DisconnectReason.Error); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var closed = Assert.IsType(inbound[1]); - Assert.Equal(99L, (long)closed.Id); - Assert.Equal(DisconnectReason.Error, closed.Reason); - } - - [Fact(Timeout = 5000)] - public async Task PushConnectionMigration_should_deliver_ConnectionMigrationDetected_inbound() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - var oldEndPoint = new System.Net.IPEndPoint(System.Net.IPAddress.Parse("192.168.1.1"), 5000); - var newEndPoint = new System.Net.IPEndPoint(System.Net.IPAddress.Parse("192.168.1.2"), 5001); - stage.PushConnectionMigration(oldEndPoint, newEndPoint); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var migration = Assert.IsType(inbound[1]); - Assert.Equal(oldEndPoint, migration.OldEndPoint); - Assert.Equal(newEndPoint, migration.NewEndPoint); - } - - [Fact(Timeout = 5000)] - public async Task WaitForMultiplexedDataAsync_should_skip_non_multiplexed_messages() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new OpenStream(1, StreamDirection.Bidirectional), - new MultiplexedData(TransportBuffer.Rent(0), 1) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), _materializer); - - var mux = await stage.WaitForMultiplexedDataAsync(ct); - Assert.Equal(1L, (long)mux.StreamId); - } - - [Fact(Timeout = 5000)] - public async Task PushStreamReadCompleted_should_deliver_StreamReadCompleted_inbound() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushStreamReadCompleted(42); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var completed = Assert.IsType(inbound[1]); - Assert.Equal(42L, (long)completed.Id); - } - - [Fact(Timeout = 5000)] - public async Task PushStreamClosed_with_error_reason_should_deliver_error() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushStreamClosed(55, DisconnectReason.Error); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var closed = Assert.IsType(inbound[1]); - Assert.Equal(55L, (long)closed.Id); - Assert.Equal(DisconnectReason.Error, closed.Reason); - } - - [Fact(Timeout = 5000)] - public async Task PushDisconnected_default_reason_should_be_graceful() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions - { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await stage.WaitForOutbound(ct); - stage.PushDisconnected(); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - var disconnected = Assert.IsType(inbound[1]); - Assert.Equal(DisconnectReason.Graceful, disconnected.Reason); - } -} diff --git a/src/Servus.Akka.TestKit.Tests/TestConnectionStageSpec.cs b/src/Servus.Akka.TestKit.Tests/TestConnectionStageSpec.cs deleted file mode 100644 index f672867a9..000000000 --- a/src/Servus.Akka.TestKit.Tests/TestConnectionStageSpec.cs +++ /dev/null @@ -1,265 +0,0 @@ -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit.Tests; - -public sealed class TestConnectionStageSpec : global::Akka.TestKit.Xunit.TestKit -{ - private readonly IMaterializer _materializer; - - public TestConnectionStageSpec() - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_materialize_and_deliver_TransportConnected_via_AutoConnect() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - var tcs = new TaskCompletionSource(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => tcs.TrySetResult(msg)), _materializer); - - var result = await tcs.Task.WaitAsync(ct); - Assert.IsType(result); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_capture_outbound_messages() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), _materializer); - - var outbound = await stage.WaitForOutbound(ct); - Assert.IsType(outbound); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_deliver_PushOnce_messages() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - stage.PushOnce(new TransportData("HTTP/1.1 200 OK\r\n\r\n"u8.ToArray())); - - var results = new List(); - var tcs = new TaskCompletionSource(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - results.Add(msg); - if (results.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - Assert.IsType(results[0]); - Assert.IsType(results[1]); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_support_bidirectional_control() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - var inboundResults = new List(); - var tcs = new TaskCompletionSource(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new TransportData(new byte[] { 1, 2, 3 }) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - inboundResults.Add(msg); - if (inboundResults.Count >= 3) - { - tcs.TrySetResult(); - } - }), _materializer); - - var outbound = await stage.WaitForOutbound(ct); - Assert.IsType(outbound); - - var dataOut = await stage.WaitForOutbound(ct); - Assert.IsType(dataOut); - - stage.PushInbound(new TransportData(new byte[] { 4, 5, 6 })); - stage.PushInbound(new TransportDisconnected(DisconnectReason.Graceful)); - - await tcs.Task.WaitAsync(ct); - Assert.IsType(inboundResults[0]); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_record_activity_log() - { - var ct = TestContext.Current.CancellationToken; - var log = new ActivityLog(); - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnOutbound((_, _) => { }) - .WithActivityLog(log) - .Build(); - - var tcs = new TaskCompletionSource(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => tcs.TrySetResult(msg)), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.Contains(log.Entries, e => e is OutboundReceived); - Assert.Contains(log.Entries, e => e is HandlerInvoked); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_invoke_typed_OnOutbound_handlers() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnOutbound((_, ctx) => - { - ctx.Push(new TransportData(new byte[] { 0xFF })); - }) - .Build(); - - var results = new List(); - var tcs = new TaskCompletionSource(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new TransportData(new byte[] { 1, 2, 3 }) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - results.Add(msg); - if (results.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - Assert.IsType(results[0]); - var responseData = Assert.IsType(results[1]); - Assert.Equal(0xFF, responseData.Buffer.Span[0]); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_support_implicit_flow_conversion() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - Flow flow = stage; - - var tcs = new TaskCompletionSource(); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(flow) - .RunWith(Sink.ForEach(msg => tcs.TrySetResult(msg)), _materializer); - - var result = await tcs.Task.WaitAsync(ct); - Assert.IsType(result); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_auto_respond_via_PushResponse() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - stage.PushResponse(outbound => outbound is TransportData - ? new TransportData(new byte[] { 0xAA }) - : null); - - var results = new List(); - var tcs = new TaskCompletionSource(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new TransportData(new byte[] { 1, 2, 3 }) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - results.Add(msg); - if (results.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - Assert.IsType(results[0]); - var data = Assert.IsType(results[1]); - Assert.Equal(0xAA, data.Buffer.Span[0]); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_support_PushResponseOnce_for_single_shot() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - stage.PushResponseOnce(_ => new TransportData(new byte[] { 0xBB })); - - var results = new List(); - var tcs = new TaskCompletionSource(); - - _ = Source.From([ - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new TransportData(new byte[] { 1 }), - new TransportData(new byte[] { 2 }) - ]) - .Via(stage.AsFlow()) - .RunWith(Sink.ForEach(msg => - { - results.Add(msg); - if (results.Count >= 2) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - Assert.IsType(results[0]); - var data = Assert.IsType(results[1]); - Assert.Equal(0xBB, data.Buffer.Span[0]); - Assert.Equal(2, results.Count); - } -} diff --git a/src/Servus.Akka.TestKit.Tests/TestListenerStageSpec.cs b/src/Servus.Akka.TestKit.Tests/TestListenerStageSpec.cs deleted file mode 100644 index 69055f471..000000000 --- a/src/Servus.Akka.TestKit.Tests/TestListenerStageSpec.cs +++ /dev/null @@ -1,267 +0,0 @@ -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit.Tests; - -public sealed class TestListenerStageSpec : global::Akka.TestKit.Xunit.TestKit -{ - private readonly IMaterializer _materializer; - - public TestListenerStageSpec() - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task Default_should_emit_AutoConnect_connections() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder().Build(); - - var flows = await listener.AsSource() - .Take(1) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - Assert.Single(flows); - - var conn = listener.GetConnection(0); - Assert.NotNull(conn); - } - - [Fact(Timeout = 5000)] - public async Task WithDefaultConnection_should_configure_emitted_connections() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder() - .WithDefaultConnection(b => b.AutoConnect()) - .Build(); - - var tcs = new TaskCompletionSource(); - - var flows = await listener.AsSource() - .Take(1) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(flows[0]) - .RunWith(Sink.ForEach(msg => tcs.TrySetResult(msg)), _materializer); - - var result = await tcs.Task.WaitAsync(ct); - Assert.IsType(result); - } - - [Fact(Timeout = 5000)] - public async Task OnAccept_should_control_per_index_behavior() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder() - .OnAccept(index => new TestConnectionStageBuilder() - .AutoConnect() - .Build()) - .Build(); - - await listener.AsSource() - .Take(2) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - Assert.Equal(2, listener.AcceptedConnections.Count); - } - - [Fact(Timeout = 5000)] - public async Task OnAccept_returning_null_should_fall_back_to_default() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder() - .WithDefaultConnection(b => b.AutoConnect()) - .OnAccept(index => index == 0 - ? new TestConnectionStageBuilder().AutoConnect().AutoDisconnect().Build() - : null) - .Build(); - - await listener.AsSource() - .Take(2) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - Assert.Equal(2, listener.AcceptedConnections.Count); - - var activity0 = listener.ActivityLog.OfType().First(a => a.Index == 0); - Assert.True(activity0.FromFactory); - - var activity1 = listener.ActivityLog.OfType().First(a => a.Index == 1); - Assert.False(activity1.FromFactory); - } - - [Fact(Timeout = 5000)] - public async Task AcceptedConnections_should_track_all_emitted_connections() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder().Build(); - - await listener.AsSource() - .Take(3) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - Assert.Equal(3, listener.AcceptedConnections.Count); - Assert.Same(listener.GetConnection(0), listener.AcceptedConnections[0]); - Assert.Same(listener.GetConnection(1), listener.AcceptedConnections[1]); - Assert.Same(listener.GetConnection(2), listener.AcceptedConnections[2]); - } - - [Fact(Timeout = 5000)] - public async Task ActivityLog_should_record_ListenerConnectionAccepted() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder() - .OnAccept(index => new TestConnectionStageBuilder().AutoConnect().Build()) - .Build(); - - await listener.AsSource() - .Take(2) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - var accepted = listener.ActivityLog.OfType().ToList(); - Assert.Equal(2, accepted.Count); - Assert.Equal(0, accepted[0].Index); - Assert.True(accepted[0].FromFactory); - Assert.Equal(1, accepted[1].Index); - Assert.True(accepted[1].FromFactory); - } - - [Fact(Timeout = 5000)] - public async Task Activities_should_expose_flat_entry_list() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder().Build(); - - await listener.AsSource() - .Take(1) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - Assert.Single(listener.Activities); - Assert.IsType(listener.Activities[0]); - } - - [Fact(Timeout = 5000)] - public async Task Emitted_connection_should_be_fully_functional_TestConnectionStage() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder() - .WithDefaultConnection(b => b.AutoConnect()) - .Build(); - - var tcs = new TaskCompletionSource(); - - var connectionFlows = await listener.AsSource() - .Take(1) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(connectionFlows[0]) - .RunWith(Sink.ForEach(msg => tcs.TrySetResult(msg)), _materializer); - - var result = await tcs.Task.WaitAsync(ct); - Assert.IsType(result); - - var conn = listener.GetConnection(0); - var outbound = await conn.WaitForOutbound(ct); - Assert.IsType(outbound); - } - - [Fact(Timeout = 5000)] - public void Implicit_source_conversion_should_work() - { - var listener = new TestListenerStageBuilder().Build(); - - Source, NotUsed> source = listener; - - Assert.NotNull(source); - } - - [Fact(Timeout = 5000)] - public async Task OnAccept_factory_should_receive_incrementing_indices() - { - var ct = TestContext.Current.CancellationToken; - var indices = new List(); - - var listener = new TestListenerStageBuilder() - .OnAccept(index => - { - indices.Add(index); - return new TestConnectionStageBuilder().AutoConnect().Build(); - }) - .Build(); - - await listener.AsSource() - .Take(3) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - Assert.Equal([0, 1, 2], indices); - } - - [Fact(Timeout = 5000)] - public void GetConnection_out_of_range_should_throw() - { - var listener = new TestListenerStageBuilder().Build(); - - var ex = Assert.Throws(() => listener.GetConnection(0)); - Assert.NotNull(ex); - } - - [Fact(Timeout = 5000)] - public async Task Builder_with_no_config_should_use_AutoConnect_default() - { - var ct = TestContext.Current.CancellationToken; - var inbound = new List(); - var tcs = new TaskCompletionSource(); - - var listener = new TestListenerStageBuilder().Build(); - - var connectionFlows = await listener.AsSource() - .Take(1) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - _ = Source.Single(new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 })) - .Via(connectionFlows[0]) - .RunWith(Sink.ForEach(msg => - { - inbound.Add(msg); - if (inbound.Count >= 1) - { - tcs.TrySetResult(); - } - }), _materializer); - - await tcs.Task.WaitAsync(ct); - - Assert.IsType(inbound[0]); - } - - [Fact(Timeout = 5000)] - public async Task Multiple_accepts_should_create_independent_connections() - { - var ct = TestContext.Current.CancellationToken; - var listener = new TestListenerStageBuilder().Build(); - - await listener.AsSource() - .Take(2) - .RunWith(Sink.Seq>(), _materializer) - .WaitAsync(TimeSpan.FromSeconds(5), ct); - - var conn0 = listener.GetConnection(0); - var conn1 = listener.GetConnection(1); - - Assert.NotSame(conn0, conn1); - } -} diff --git a/src/Servus.Akka.TestKit.Tests/TestPipelineSpec.cs b/src/Servus.Akka.TestKit.Tests/TestPipelineSpec.cs deleted file mode 100644 index ace87b0b3..000000000 --- a/src/Servus.Akka.TestKit.Tests/TestPipelineSpec.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Akka.Streams; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit.Tests; - -public sealed class TestPipelineSpec : global::Akka.TestKit.Xunit.TestKit -{ - private readonly IMaterializer _materializer; - - public TestPipelineSpec() - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task RunAsync_should_return_single_result() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .Build(); - - var result = await TestPipeline.RunAsync( - stage.AsFlow(), - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - _materializer, ct: ct); - - Assert.IsType(result); - } - - [Fact(Timeout = 5000)] - public async Task RunManyAsync_should_collect_expected_count() - { - var ct = TestContext.Current.CancellationToken; - var stage = new TestConnectionStageBuilder() - .AutoConnect() - .OnOutbound((_, ctx) => - ctx.Push(new TransportData(new byte[] { 0x01 }))) - .Build(); - - var inputs = new ITransportOutbound[] - { - new ConnectTransport(new TcpTransportOptions { Host = "localhost", Port = 80 }), - new TransportData(new byte[] { 1 }), - new TransportData(new byte[] { 2 }) - }; - - var results = await TestPipeline.RunManyAsync( - stage.AsFlow(), inputs, 3, _materializer, ct: ct); - - Assert.Equal(3, results.Count); - Assert.IsType(results[0]); - Assert.IsType(results[1]); - Assert.IsType(results[2]); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.TestKit.Tests/xunit.runner.json b/src/Servus.Akka.TestKit.Tests/xunit.runner.json deleted file mode 100644 index 1a57b530a..000000000 --- a/src/Servus.Akka.TestKit.Tests/xunit.runner.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeTestCollections": true, - "parallelizeAssembly": false, - "maxParallelThreads": 2 -} diff --git a/src/Servus.Akka.TestKit/ActivityLog.cs b/src/Servus.Akka.TestKit/ActivityLog.cs deleted file mode 100644 index 5845c5573..000000000 --- a/src/Servus.Akka.TestKit/ActivityLog.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit; - -public abstract record Activity -{ - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; -} - -public sealed record OutboundReceived(int Index, ITransportOutbound Message) : Activity; - -public sealed record InboundPushed(int Index, ITransportInbound Message) : Activity; - -public sealed record HandlerInvoked(string HandlerType, ITransportOutbound Trigger) : Activity; - -public sealed record StageCompleted : Activity; - -public sealed record StageFailed(Exception Exception) : Activity; - -public sealed class ActivityLog -{ - private readonly List _entries = []; - - public IReadOnlyList Entries => _entries; - - public void Record(Activity activity) => _entries.Add(activity); - - public IEnumerable OfType() where T : Activity - => _entries.OfType(); - - public void Clear() => _entries.Clear(); -} - -public sealed record ListenerConnectionAccepted(int Index, bool FromFactory) : Activity; diff --git a/src/Servus.Akka.TestKit/BehaviorStack.cs b/src/Servus.Akka.TestKit/BehaviorStack.cs deleted file mode 100644 index 4b6ff389c..000000000 --- a/src/Servus.Akka.TestKit/BehaviorStack.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Servus.Akka.TestKit; - -public sealed class BehaviorStack -{ - private readonly Func _default; - private readonly Stack> _stack = new(); - - public BehaviorStack(Func defaultBehavior) - { - _default = defaultBehavior; - } - - public void Push(Func behavior) => _stack.Push(behavior); - - public void PushConstant(TOut value) => Push(_ => value); - - public void PushError(Exception exception) => Push(_ => throw exception); - - public DelayGate PushDelayed() - { - var gate = new DelayGate(); - Push(gate.Execute); - return gate; - } - - public void PushOnce(Func behavior) - { - Push(input => - { - Pop(); - return behavior(input); - }); - } - - public void Pop() => _stack.TryPop(out _); - - public TOut Apply(TIn input) - { - if (_stack.TryPeek(out var behavior)) - { - return behavior(input); - } - - return _default(input); - } -} - -public sealed class DelayGate -{ - private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - internal TOut Execute(TIn _) => _tcs.Task.GetAwaiter().GetResult(); - - public void Release(TOut value) => _tcs.TrySetResult(value); - - public void Fault(Exception exception) => _tcs.TrySetException(exception); -} diff --git a/src/Servus.Akka.TestKit/IStageContext.cs b/src/Servus.Akka.TestKit/IStageContext.cs deleted file mode 100644 index f9104f8ad..000000000 --- a/src/Servus.Akka.TestKit/IStageContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit; - -public interface IStageContext -{ - void Push(ITransportInbound inbound); - void Complete(); - void Fail(Exception ex); - void ScheduleTimer(string key, TimeSpan delay); - void CancelTimer(string key); -} diff --git a/src/Servus.Akka.TestKit/Servus.Akka.TestKit.csproj b/src/Servus.Akka.TestKit/Servus.Akka.TestKit.csproj deleted file mode 100644 index fc9b5af71..000000000 --- a/src/Servus.Akka.TestKit/Servus.Akka.TestKit.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - false - - - - - - - - - - - diff --git a/src/Servus.Akka.TestKit/TestConnectionStage.cs b/src/Servus.Akka.TestKit/TestConnectionStage.cs deleted file mode 100644 index 894153e8b..000000000 --- a/src/Servus.Akka.TestKit/TestConnectionStage.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System.Collections.Concurrent; -using System.Threading.Channels; -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.Streams.Stage; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit; - -public sealed class TestConnectionStage : GraphStage> -{ - private readonly List _handlers; - private readonly ActivityLog? _activityLog; - private readonly BehaviorStack _responses = new(_ => null); - private readonly Queue _initialInbound = new(); - - private readonly Channel _inboundChannel = - Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }); - - private readonly Channel _outboundChannel = - Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = false, - SingleWriter = true - }); - - private readonly ConcurrentBag _receivedOutbound = []; - - private int _outboundIndex; - private int _inboundIndex; - - public Inlet In { get; } = new("TestConnection.In"); - public Outlet Out { get; } = new("TestConnection.Out"); - - public override FlowShape Shape { get; } - - internal TestConnectionStage(List handlers, ActivityLog? activityLog) - { - _handlers = handlers; - _activityLog = activityLog; - Shape = new FlowShape(In, Out); - } - - internal void EnqueueInitial(ITransportInbound message) - => _initialInbound.Enqueue(message); - - public void PushOnce(ITransportInbound message) - => _inboundChannel.Writer.TryWrite(message); - - public void PushInbound(ITransportInbound message) - => _inboundChannel.Writer.TryWrite(message); - - public async Task WaitForOutbound(CancellationToken ct = default) - => await _outboundChannel.Reader.ReadAsync(ct).ConfigureAwait(false); - - public bool TryGetOutbound(out ITransportOutbound? message) - => _outboundChannel.Reader.TryRead(out message); - - public IReadOnlyCollection ReceivedOutbound => _receivedOutbound; - - public void PushResponse(Func handler) - => _responses.Push(handler); - - public void PushResponseOnce(Func handler) - => _responses.PushOnce(handler); - - public void PushResponseConstant(ITransportInbound response) - => _responses.PushConstant(response); - - public void PushResponseError(Exception exception) - => _responses.PushError(exception); - - public DelayGate PushResponseDelayed() - => _responses.PushDelayed(); - - public void PopResponse() - => _responses.Pop(); - - public static implicit operator Flow(TestConnectionStage stage) - => Flow.FromGraph(stage); - - public Flow AsFlow() - => Flow.FromGraph(this); - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new Logic(this); - - private sealed class Logic : TimerGraphStageLogic, IStageContext - { - private readonly TestConnectionStage _stage; - private readonly Queue _pendingInbound = new(); - private bool _downstreamWaiting; - private Action? _onInboundCallback; - - public Logic(TestConnectionStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage.In, - onPush: () => - { - var item = Grab(stage.In); - var index = _stage._outboundIndex++; - - _stage._receivedOutbound.Add(item); - _stage._outboundChannel.Writer.TryWrite(item); - _stage._activityLog?.Record(new OutboundReceived(index, item)); - - InvokeHandlers(item); - - if (!IsClosed(stage.In)) - { - Pull(stage.In); - } - - TryPushNext(); - }, - onUpstreamFinish: () => - { - _stage._outboundChannel.Writer.TryComplete(); - }, - onUpstreamFailure: ex => - { - _stage._activityLog?.Record(new StageFailed(ex)); - FailStage(ex); - }); - - SetHandler(stage.Out, - onPull: () => - { - _downstreamWaiting = true; - TryPushNext(); - }, - onDownstreamFinish: _ => - { - if (!IsClosed(stage.In)) - { - Cancel(stage.In); - } - - _stage._outboundChannel.Writer.TryComplete(); - }); - } - - public override void PreStart() - { - while (_stage._initialInbound.TryDequeue(out var initial)) - { - _pendingInbound.Enqueue(initial); - } - - _onInboundCallback = GetAsyncCallback(inbound => - { - _pendingInbound.Enqueue(inbound); - TryPushNext(); - }); - - Pull(_stage.In); - ScheduleInboundPoll(); - } - - public override void PostStop() - { - _stage._activityLog?.Record(new StageCompleted()); - _stage._outboundChannel.Writer.TryComplete(); - _stage._inboundChannel.Writer.TryComplete(); - } - - protected override void OnTimer(object timerKey) - { - } - - private void ScheduleInboundPoll() - { - var callback = _onInboundCallback!; - var reader = _stage._inboundChannel.Reader; - - _ = Task.Run(async () => - { - try - { - await foreach (var item in reader.ReadAllAsync()) - { - callback(item); - } - } - catch (ChannelClosedException) - { - } - }); - } - - private void TryPushNext() - { - if (!_downstreamWaiting) - { - return; - } - - if (_pendingInbound.TryDequeue(out var next)) - { - _downstreamWaiting = false; - Push(_stage.Out, next); - } - } - - private void InvokeHandlers(ITransportOutbound item) - { - var itemType = item.GetType(); - foreach (var handler in _stage._handlers) - { - if (handler.MessageType.IsAssignableFrom(itemType)) - { - _stage._activityLog?.Record( - new HandlerInvoked(itemType.Name, item)); - handler.Invoke(item, this); - } - } - - var response = _stage._responses.Apply(item); - if (response is not null) - { - ((IStageContext)this).Push(response); - } - } - - void IStageContext.Push(ITransportInbound inbound) - { - var index = _stage._inboundIndex++; - _stage._activityLog?.Record(new InboundPushed(index, inbound)); - _pendingInbound.Enqueue(inbound); - TryPushNext(); - } - - void IStageContext.Complete() => CompleteStage(); - - void IStageContext.Fail(Exception ex) - { - _stage._activityLog?.Record(new StageFailed(ex)); - FailStage(ex); - } - - void IStageContext.ScheduleTimer(string key, TimeSpan delay) => ScheduleOnce(key, delay); - - void IStageContext.CancelTimer(string key) => CancelTimer(key); - } - - internal sealed class OutboundHandler(Type messageType, Action handler) - { - public Type MessageType { get; } = messageType; - - public void Invoke(ITransportOutbound message, IStageContext context) => handler(message, context); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.TestKit/TestConnectionStageBuilder.cs b/src/Servus.Akka.TestKit/TestConnectionStageBuilder.cs deleted file mode 100644 index 9637ab716..000000000 --- a/src/Servus.Akka.TestKit/TestConnectionStageBuilder.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit; - -public sealed class TestConnectionStageBuilder -{ - private readonly List _handlers = []; - private ActivityLog? _activityLog; - private ConnectionInfo? _autoConnectInfo; - private bool _autoConnect; - - public TestConnectionStageBuilder AutoConnect(ConnectionInfo? info = null) - { - _autoConnect = true; - _autoConnectInfo = info ?? ConnectionInfo.None; - return this; - } - - public TestConnectionStageBuilder AutoDisconnect() - { - return OnOutbound((msg, ctx) - => ctx.Push(new TransportDisconnected(msg.Reason))); - } - - public TestConnectionStageBuilder OnOutbound(Action handler) - where T : ITransportOutbound - { - _handlers.Add(new TestConnectionStage.OutboundHandler( - typeof(T), - (msg, ctx) => handler((T)msg, ctx))); - return this; - } - - public TestConnectionStageBuilder WithActivityLog(ActivityLog log) - { - _activityLog = log; - return this; - } - - public TestConnectionStage Build() - { - var stage = new TestConnectionStage([.. _handlers], _activityLog); - - if (_autoConnect) - { - stage.EnqueueInitial(new TransportConnected(_autoConnectInfo!)); - } - - return stage; - } -} diff --git a/src/Servus.Akka.TestKit/TestConnectionStageBuilderExtensions.cs b/src/Servus.Akka.TestKit/TestConnectionStageBuilderExtensions.cs deleted file mode 100644 index b24ae5b3a..000000000 --- a/src/Servus.Akka.TestKit/TestConnectionStageBuilderExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit; - -public static class TestConnectionStageBuilderExtensions -{ - public static TestConnectionStageBuilder OnData(this TestConnectionStageBuilder builder, Action handler) - => builder.OnOutbound(handler); - - public static TestConnectionStageBuilder OnOpenStream(this TestConnectionStageBuilder builder, Action handler) - => builder.OnOutbound(handler); - - public static TestConnectionStageBuilder OnMultiplexedData(this TestConnectionStageBuilder builder, Action handler) - => builder.OnOutbound(handler); - - public static TestConnectionStageBuilder OnCompleteWrites(this TestConnectionStageBuilder builder, Action handler) - => builder.OnOutbound(handler); - - public static TestConnectionStageBuilder OnResetStream(this TestConnectionStageBuilder builder, Action handler) - => builder.OnOutbound(handler); - - public static TestConnectionStageBuilder OnDisconnect(this TestConnectionStageBuilder builder, Action handler) - => builder.OnOutbound(handler); - - public static TestConnectionStageBuilder AutoStreamOpened(this TestConnectionStageBuilder builder, long streamId, StreamDirection direction = StreamDirection.Bidirectional) - { - return builder.OnOutbound((open, ctx) => - { - if (open.StreamId == streamId) - { - ctx.Push(new StreamOpened(streamId, direction)); - } - }); - } - - public static TestConnectionStageBuilder EchoMultiplexedData(this TestConnectionStageBuilder builder) - { - return builder.OnOutbound((data, ctx) => - { - var echo = TransportBuffer.Rent(data.Buffer.Length); - data.Buffer.Span.CopyTo(echo.FullMemory.Span); - echo.Length = data.Buffer.Length; - ctx.Push(data with { Buffer = echo }); - }); - } -} diff --git a/src/Servus.Akka.TestKit/TestConnectionStageExtensions.cs b/src/Servus.Akka.TestKit/TestConnectionStageExtensions.cs deleted file mode 100644 index 2346c062f..000000000 --- a/src/Servus.Akka.TestKit/TestConnectionStageExtensions.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Text; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit; - -public static class TestConnectionStageExtensions -{ - public static void PushData(this TestConnectionStage stage, byte[] data) - => stage.PushInbound(new TransportData(data)); - - public static void PushData(this TestConnectionStage stage, string text) - => stage.PushInbound(new TransportData(Encoding.UTF8.GetBytes(text))); - - public static void PushDisconnected(this TestConnectionStage stage, - DisconnectReason reason = DisconnectReason.Graceful) - => stage.PushInbound(new TransportDisconnected(reason)); - - public static async Task WaitForDataAsync(this TestConnectionStage stage, - CancellationToken ct = default) - { - while (true) - { - var msg = await stage.WaitForOutbound(ct).ConfigureAwait(false); - if (msg is TransportData data) - { - return data; - } - } - } - - public static void PushStreamOpened(this TestConnectionStage stage, long streamId, - StreamDirection direction = StreamDirection.Bidirectional) - => stage.PushInbound(new StreamOpened(streamId, direction)); - - public static void PushStreamClosed(this TestConnectionStage stage, long streamId, - DisconnectReason reason = DisconnectReason.Graceful) - => stage.PushInbound(new StreamClosed(streamId, reason)); - - public static void PushStreamReadCompleted(this TestConnectionStage stage, long streamId) - => stage.PushInbound(new StreamReadCompleted(streamId)); - - public static void PushServerStreamAccepted(this TestConnectionStage stage, long streamId, - StreamDirection direction = StreamDirection.Unidirectional) - => stage.PushInbound(new ServerStreamAccepted(streamId, direction)); - - public static void PushMultiplexedData(this TestConnectionStage stage, long streamId, byte[] data) - { - var buf = TransportBuffer.Rent(data.Length); - data.CopyTo(buf.FullMemory.Span); - buf.Length = data.Length; - stage.PushInbound(new MultiplexedData(buf, streamId)); - } - - public static void PushConnectionMigration(this TestConnectionStage stage, System.Net.EndPoint oldEndPoint, - System.Net.EndPoint newEndPoint) - => stage.PushInbound(new ConnectionMigrationDetected(oldEndPoint, newEndPoint)); - - public static void SimulateInboundStream(this TestConnectionStage stage, long streamId, StreamDirection direction, - params byte[][] frames) - { - stage.PushInbound(new ServerStreamAccepted(streamId, direction)); - - foreach (var frame in frames) - { - var buf = TransportBuffer.Rent(frame.Length); - frame.CopyTo(buf.FullMemory.Span); - buf.Length = frame.Length; - stage.PushInbound(new MultiplexedData(buf, streamId)); - } - - stage.PushInbound(new StreamReadCompleted(streamId)); - } - - public static async Task WaitForMultiplexedDataAsync(this TestConnectionStage stage, - CancellationToken ct = default) - { - while (true) - { - var msg = await stage.WaitForOutbound(ct).ConfigureAwait(false); - if (msg is MultiplexedData data) - { - return data; - } - } - } - - public static async Task WaitForOpenStreamAsync(this TestConnectionStage stage, - CancellationToken ct = default) - { - while (true) - { - var msg = await stage.WaitForOutbound(ct).ConfigureAwait(false); - if (msg is OpenStream open) - { - return open; - } - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka.TestKit/TestListenerStage.cs b/src/Servus.Akka.TestKit/TestListenerStage.cs deleted file mode 100644 index 5a0cade9c..000000000 --- a/src/Servus.Akka.TestKit/TestListenerStage.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.Streams.Stage; -using Servus.Akka.Transport; - -namespace Servus.Akka.TestKit; - -public sealed class TestListenerStage - : GraphStage>> -{ - private readonly Action? _defaultFactory; - private readonly Func? _onAccept; - private readonly List _acceptedConnections = []; - private int _acceptIndex; - - private readonly Outlet> _out = - new("TestListener.Out"); - - public override SourceShape> Shape { get; } - - public ActivityLog ActivityLog { get; } = new(); - - public IReadOnlyList Activities => ActivityLog.Entries; - - public IReadOnlyList AcceptedConnections => _acceptedConnections; - - internal TestListenerStage( - Action? defaultFactory, - Func? onAccept) - { - _defaultFactory = defaultFactory; - _onAccept = onAccept; - Shape = new SourceShape>(_out); - } - - public TestConnectionStage GetConnection(int index) => _acceptedConnections[index]; - - public Source, NotUsed> AsSource() - => Source.FromGraph(this); - - public static implicit operator - Source, NotUsed>(TestListenerStage stage) - => stage.AsSource(); - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new Logic(this); - - private TestConnectionStage ResolveConnection() - { - var index = _acceptIndex++; - var fromFactory = false; - - TestConnectionStage? connection = null; - - if (_onAccept is not null) - { - connection = _onAccept(index); - fromFactory = connection is not null; - } - - connection ??= BuildDefault(); - - _acceptedConnections.Add(connection); - ActivityLog.Record(new ListenerConnectionAccepted(index, fromFactory)); - - return connection; - } - - private TestConnectionStage BuildDefault() - { - var builder = new TestConnectionStageBuilder(); - - if (_defaultFactory is not null) - { - _defaultFactory(builder); - } - else - { - builder.AutoConnect(); - } - - return builder.Build(); - } - - private sealed class Logic : GraphStageLogic - { - private readonly TestListenerStage _stage; - - public Logic(TestListenerStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage._out, onPull: () => - { - var connection = _stage.ResolveConnection(); - Push(_stage._out, connection.AsFlow()); - }); - } - } -} diff --git a/src/Servus.Akka.TestKit/TestListenerStageBuilder.cs b/src/Servus.Akka.TestKit/TestListenerStageBuilder.cs deleted file mode 100644 index 269282fec..000000000 --- a/src/Servus.Akka.TestKit/TestListenerStageBuilder.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Servus.Akka.TestKit; - -public sealed class TestListenerStageBuilder -{ - private Action? _defaultFactory; - private Func? _onAccept; - - public TestListenerStageBuilder WithDefaultConnection(Action configure) - { - _defaultFactory = configure; - return this; - } - - public TestListenerStageBuilder OnAccept(Func factory) - { - _onAccept = factory; - return this; - } - - public TestListenerStage Build() - { - return new TestListenerStage(_defaultFactory, _onAccept); - } -} diff --git a/src/Servus.Akka.TestKit/TestPipeline.cs b/src/Servus.Akka.TestKit/TestPipeline.cs deleted file mode 100644 index d9d32810a..000000000 --- a/src/Servus.Akka.TestKit/TestPipeline.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; - -namespace Servus.Akka.TestKit; - -public static class TestPipeline -{ - private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5); - - public static async Task RunAsync( - Flow flow, - TIn input, - IMaterializer materializer, - TimeSpan? timeout = null, - CancellationToken ct = default) - { - var result = Source.Single(input) - .Via(flow) - .RunWith(Sink.First(), materializer); - - return await result.WaitAsync(timeout ?? DefaultTimeout, ct).ConfigureAwait(false); - } - - public static async Task> RunManyAsync( - Flow flow, - IEnumerable inputs, - int expectedCount, - IMaterializer materializer, - TimeSpan? timeout = null, - CancellationToken ct = default) - { - var result = Source.From(inputs) - .Via(flow) - .Take(expectedCount) - .RunWith(Sink.Seq(), materializer); - - return await result.WaitAsync(timeout ?? DefaultTimeout, ct).ConfigureAwait(false); - } -} diff --git a/src/Servus.Akka.Tests/Servus.Akka.Tests.csproj b/src/Servus.Akka.Tests/Servus.Akka.Tests.csproj deleted file mode 100644 index 6fec1e1e8..000000000 --- a/src/Servus.Akka.Tests/Servus.Akka.Tests.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - Exe - true - - CA1416 - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Streams/IO/PipeSinkStageSpec.cs b/src/Servus.Akka.Tests/Streams/IO/PipeSinkStageSpec.cs deleted file mode 100644 index a538f1a21..000000000 --- a/src/Servus.Akka.Tests/Streams/IO/PipeSinkStageSpec.cs +++ /dev/null @@ -1,602 +0,0 @@ -using System.IO.Pipelines; -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.TestKit.Xunit; -using Servus.Akka.Streams.IO; - -namespace Servus.Akka.Tests.Streams.IO; - -public sealed class PipeSinkStageSpec : TestKit -{ - private readonly IMaterializer _materializer; - - public PipeSinkStageSpec() : base(ActorSystem.Create("test")) - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_write_data_to_pipe_reader() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var data = new byte[] { 1, 2, 3, 4, 5 }; - await Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - var readResult = await pipe.Reader.ReadAsync(CancellationToken.None); - Assert.Equal(data, readResult.Buffer.FirstSpan.ToArray()); - pipe.Reader.AdvanceTo(readResult.Buffer.End); - - var finalRead = await pipe.Reader.ReadAsync(CancellationToken.None); - Assert.True(finalRead.IsCompleted); - await pipe.Reader.CompleteAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_write_multiple_chunks_to_pipe_reader() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var chunks = new[] - { - new byte[] { 1, 2, 3 }, - new byte[] { 4, 5, 6 }, - new byte[] { 7, 8, 9 } - }; - - var writeTask = Source.From(chunks.Select(c => (ReadOnlyMemory)c.AsMemory())) - .RunWith(sink, _materializer); - - var total = new List(); - while (true) - { - var readResult = await pipe.Reader.ReadAsync(CancellationToken.None); - foreach (var segment in readResult.Buffer) - { - total.AddRange(segment.ToArray()); - } - - pipe.Reader.AdvanceTo(readResult.Buffer.End); - - if (readResult.IsCompleted) - { - break; - } - } - - await pipe.Reader.CompleteAsync(); - await writeTask; - Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, total.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_complete_task_when_upstream_finishes() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var task = Source.Empty>() - .RunWith(sink, _materializer); - - await task; - - var readResult = await pipe.Reader.ReadAsync(CancellationToken.None); - Assert.True(readResult.IsCompleted); - Assert.True(readResult.Buffer.IsEmpty); - await pipe.Reader.CompleteAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_fault_task_when_upstream_fails() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var error = new InvalidOperationException("test failure"); - var task = Source.Failed>(error) - .RunWith(sink, _materializer); - - var ex = await Assert.ThrowsAsync(() => task); - Assert.Equal("test failure", ex.Message); - await pipe.Reader.CompleteAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_skip_empty_chunks() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var chunks = new[] - { - ReadOnlyMemory.Empty, - (ReadOnlyMemory)new byte[] { 1, 2, 3 }, - ReadOnlyMemory.Empty - }; - - var writeTask = Source.From(chunks) - .RunWith(sink, _materializer); - - var total = new List(); - while (true) - { - var readResult = await pipe.Reader.ReadAsync(CancellationToken.None); - foreach (var segment in readResult.Buffer) - { - total.AddRange(segment.ToArray()); - } - - pipe.Reader.AdvanceTo(readResult.Buffer.End); - - if (readResult.IsCompleted) - { - break; - } - } - - await pipe.Reader.CompleteAsync(); - await writeTask; - Assert.Equal(new byte[] { 1, 2, 3 }, total.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_to_stream_should_write_data() - { - var memoryStream = new MemoryStream(); - var sink = StreamSink.To(memoryStream); - - var data = new byte[] { 10, 20, 30 }; - await Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - Assert.Equal(data, memoryStream.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_empty_chunks() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var chunks = new[] - { - ReadOnlyMemory.Empty, - (ReadOnlyMemory)new byte[] { 1, 2 }, - ReadOnlyMemory.Empty, - (ReadOnlyMemory)new byte[] { 3, 4 }, - ReadOnlyMemory.Empty - }; - - var writeTask = Source.From(chunks) - .RunWith(sink, _materializer); - - var total = new List(); - while (true) - { - var readResult = await pipe.Reader.ReadAsync(CancellationToken.None); - foreach (var segment in readResult.Buffer) - { - total.AddRange(segment.ToArray()); - } - - pipe.Reader.AdvanceTo(readResult.Buffer.End); - - if (readResult.IsCompleted) - { - break; - } - } - - await pipe.Reader.CompleteAsync(); - await writeTask; - Assert.Equal(new byte[] { 1, 2, 3, 4 }, total.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_complete_on_normal_write() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var data = new byte[] { 5, 10, 15 }; - _ = Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - var result = await pipe.Reader.ReadAsync(TestContext.Current.CancellationToken); - Assert.Equal(3, result.Buffer.Length); - pipe.Reader.AdvanceTo(result.Buffer.End); - await pipe.Reader.CompleteAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_flush_result_is_completed() - { - var pipe = new CompletedFlushResultPipe(); - var sink = PipeSink.To(pipe.Writer); - - var data = new byte[] { 20, 30 }; - var task = Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - await task; - Assert.True(pipe.WriteWasCalled); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_flush_result_is_canceled() - { - var pipe = new CanceledFlushResultPipe(); - var sink = PipeSink.To(pipe.Writer); - - var data = "(2"u8.ToArray(); - var task = Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - await task; - Assert.True(pipe.WriteWasCalled); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_upstream_failure() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var error = new InvalidOperationException("upstream error"); - var task = Source.Failed>(error) - .RunWith(sink, _materializer); - - var ex = await Assert.ThrowsAsync(() => task); - Assert.Equal("upstream error", ex.Message); - await pipe.Reader.CompleteAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_synchronous_write_completion() - { - var pipe = new SynchronousWritePipe(); - var sink = PipeSink.To(pipe.Writer); - - var data = ")data.AsMemory()) - .RunWith(sink, _materializer); - - await task; - Assert.True(pipe.WriteWasCalled); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_asynchronous_write_completion() - { - var pipe = new SlowWritePipe(delayMs: 50); - var sink = PipeSink.To(pipe.Writer); - - var data = "PZ"u8.ToArray(); - var task = Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - await task; - Assert.True(pipe.WriteWasCalled); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_continuous_writes_with_flush_completion() - { - var pipe = new Pipe(); - var sink = PipeSink.To(pipe.Writer); - - var chunks = new[] - { - new byte[] { 1, 2 }, - new byte[] { 3, 4 }, - new byte[] { 5, 6 } - }; - - var writeTask = Source.From(chunks.Select(c => (ReadOnlyMemory)c.AsMemory())) - .RunWith(sink, _materializer); - - var total = new List(); - while (true) - { - var readResult = await pipe.Reader.ReadAsync(CancellationToken.None); - foreach (var segment in readResult.Buffer) - { - total.AddRange(segment.ToArray()); - } - - pipe.Reader.AdvanceTo(readResult.Buffer.End); - - if (readResult.IsCompleted) - { - break; - } - } - - await pipe.Reader.CompleteAsync(); - await writeTask; - Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, total.ToArray()); - } - - private sealed class CompletedFlushResultPipe - { - private readonly Pipe _pipe = new(); - public bool WriteWasCalled { get; set; } - - public PipeWriter Writer { get; } - - public CompletedFlushResultPipe() - { - Writer = new CompletedResultPipeWriter(_pipe.Writer, this); - } - - private sealed class CompletedResultPipeWriter : PipeWriter - { - private readonly PipeWriter _inner; - private readonly CompletedFlushResultPipe _owner; - - public CompletedResultPipeWriter(PipeWriter inner, CompletedFlushResultPipe owner) - { - _inner = inner; - _owner = owner; - } - - public override void Advance(int bytes) - { - _inner.Advance(bytes); - } - - public override Memory GetMemory(int sizeHint = 0) - { - return _inner.GetMemory(sizeHint); - } - - public override Span GetSpan(int sizeHint = 0) - { - return _inner.GetSpan(sizeHint); - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) - { - _owner.WriteWasCalled = true; - return new ValueTask(new FlushResult(isCompleted: true, isCanceled: false)); - } - - public override async ValueTask FlushAsync(CancellationToken cancellationToken = default) - { - return await _inner.FlushAsync(cancellationToken); - } - - public override void CancelPendingFlush() - { - _inner.CancelPendingFlush(); - } - - public override void Complete(Exception? exception = null) - { - _inner.Complete(exception); - } - - public override async ValueTask CompleteAsync(Exception? exception = null) - { - await _inner.CompleteAsync(exception); - } - } - } - - private sealed class CanceledFlushResultPipe - { - private readonly Pipe _pipe = new(); - public bool WriteWasCalled { get; set; } - - public PipeWriter Writer { get; } - - public CanceledFlushResultPipe() - { - Writer = new CanceledResultPipeWriter(_pipe.Writer, this); - } - - private sealed class CanceledResultPipeWriter : PipeWriter - { - private readonly PipeWriter _inner; - private readonly CanceledFlushResultPipe _owner; - - public CanceledResultPipeWriter(PipeWriter inner, CanceledFlushResultPipe owner) - { - _inner = inner; - _owner = owner; - } - - public override void Advance(int bytes) - { - _inner.Advance(bytes); - } - - public override Memory GetMemory(int sizeHint = 0) - { - return _inner.GetMemory(sizeHint); - } - - public override Span GetSpan(int sizeHint = 0) - { - return _inner.GetSpan(sizeHint); - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) - { - _owner.WriteWasCalled = true; - return new ValueTask(new FlushResult(isCompleted: false, isCanceled: true)); - } - - public override async ValueTask FlushAsync(CancellationToken cancellationToken = default) - { - return await _inner.FlushAsync(cancellationToken); - } - - public override void CancelPendingFlush() - { - _inner.CancelPendingFlush(); - } - - public override void Complete(Exception? exception = null) - { - _inner.Complete(exception); - } - - public override async ValueTask CompleteAsync(Exception? exception = null) - { - await _inner.CompleteAsync(exception); - } - } - } - - private sealed class SynchronousWritePipe - { - private readonly Pipe _pipe = new(); - public bool WriteWasCalled { get; set; } - - public PipeWriter Writer { get; } - - public SynchronousWritePipe() - { - Writer = new SyncPipeWriter(_pipe.Writer, this); - } - - private sealed class SyncPipeWriter : PipeWriter - { - private readonly PipeWriter _inner; - private readonly SynchronousWritePipe _owner; - - public SyncPipeWriter(PipeWriter inner, SynchronousWritePipe owner) - { - _inner = inner; - _owner = owner; - } - - public override void Advance(int bytes) - { - _inner.Advance(bytes); - } - - public override Memory GetMemory(int sizeHint = 0) - { - return _inner.GetMemory(sizeHint); - } - - public override Span GetSpan(int sizeHint = 0) - { - return _inner.GetSpan(sizeHint); - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) - { - _owner.WriteWasCalled = true; - var span = _inner.GetSpan(buffer.Length); - buffer.Span.CopyTo(span); - _inner.Advance(buffer.Length); - return new ValueTask(new FlushResult(isCompleted: false, isCanceled: false)); - } - - public override async ValueTask FlushAsync(CancellationToken cancellationToken = default) - { - return await _inner.FlushAsync(cancellationToken); - } - - public override void CancelPendingFlush() - { - _inner.CancelPendingFlush(); - } - - public override void Complete(Exception? exception = null) - { - _inner.Complete(exception); - } - - public override async ValueTask CompleteAsync(Exception? exception = null) - { - await _inner.CompleteAsync(exception); - } - } - } - - private sealed class SlowWritePipe - { - private readonly Pipe _pipe = new(); - private readonly int _delayMs; - public bool WriteWasCalled { get; set; } - - public PipeWriter Writer { get; } - - public SlowWritePipe(int delayMs) - { - _delayMs = delayMs; - Writer = new SlowPipeWriter(_pipe.Writer, this, delayMs); - } - - private sealed class SlowPipeWriter : PipeWriter - { - private readonly PipeWriter _inner; - private readonly SlowWritePipe _owner; - private readonly int _delayMs; - - public SlowPipeWriter(PipeWriter inner, SlowWritePipe owner, int delayMs) - { - _inner = inner; - _owner = owner; - _delayMs = delayMs; - } - - public override void Advance(int bytes) - { - _inner.Advance(bytes); - } - - public override Memory GetMemory(int sizeHint = 0) - { - return _inner.GetMemory(sizeHint); - } - - public override Span GetSpan(int sizeHint = 0) - { - return _inner.GetSpan(sizeHint); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) - { - _owner.WriteWasCalled = true; - await Task.Delay(_delayMs, cancellationToken); - var span = _inner.GetSpan(buffer.Length); - buffer.Span.CopyTo(span); - _inner.Advance(buffer.Length); - return new FlushResult(isCompleted: false, isCanceled: false); - } - - public override async ValueTask FlushAsync(CancellationToken cancellationToken = default) - { - return await _inner.FlushAsync(cancellationToken); - } - - public override void CancelPendingFlush() - { - _inner.CancelPendingFlush(); - } - - public override void Complete(Exception? exception = null) - { - _inner.Complete(exception); - } - - public override async ValueTask CompleteAsync(Exception? exception = null) - { - await _inner.CompleteAsync(exception); - } - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Streams/IO/PipeSourceStageSpec.cs b/src/Servus.Akka.Tests/Streams/IO/PipeSourceStageSpec.cs deleted file mode 100644 index c1dcb2393..000000000 --- a/src/Servus.Akka.Tests/Streams/IO/PipeSourceStageSpec.cs +++ /dev/null @@ -1,261 +0,0 @@ -using System.IO.Pipelines; -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.TestKit.Xunit; -using Servus.Akka.Streams.IO; - -namespace Servus.Akka.Tests.Streams.IO; - -public sealed class PipeSourceStageSpec : TestKit -{ - private readonly IMaterializer _materializer; - - public PipeSourceStageSpec() : base(ActorSystem.Create("test")) - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_emit_data_written_to_pipe() - { - var pipe = new Pipe(); - var source = PipeSource.From(pipe.Reader); - - var data = new byte[] { 1, 2, 3, 4, 5 }; - await pipe.Writer.WriteAsync(data, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var result = await source - .RunWith(Sink.Seq>(), _materializer); - - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(data, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_complete_on_empty_pipe() - { - var pipe = new Pipe(); - await pipe.Writer.CompleteAsync(); - - var source = PipeSource.From(pipe.Reader); - - var result = await source - .RunWith(Sink.Seq>(), _materializer); - - Assert.Empty(result); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_emit_multiple_chunks() - { - var pipe = new Pipe(); - var source = PipeSource.From(pipe.Reader); - - await pipe.Writer.WriteAsync(new byte[] { 1, 2, 3 }, CancellationToken.None); - await pipe.Writer.FlushAsync(CancellationToken.None); - await pipe.Writer.WriteAsync(new byte[] { 4, 5, 6 }, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var result = await source - .RunWith(Sink.Seq>(), _materializer); - - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_handle_incremental_writes() - { - var pipe = new Pipe(); - var source = PipeSource.From(pipe.Reader); - - var collectTask = source - .RunWith(Sink.Seq>(), _materializer); - - await pipe.Writer.WriteAsync(new byte[] { 10, 20 }, CancellationToken.None); - await pipe.Writer.FlushAsync(CancellationToken.None); - await Task.Delay(50, TestContext.Current.CancellationToken); - await pipe.Writer.WriteAsync(new byte[] { 30 }, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var result = await collectTask; - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(new byte[] { 10, 20, 30 }, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_from_stream_should_emit_data() - { - var data = new byte[] { 1, 2, 3, 4, 5 }; - var stream = new MemoryStream(data); - - var source = StreamSource.From(stream); - - var result = await source - .RunWith(Sink.Seq>(), _materializer); - - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(data, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_handle_multi_segment_buffer() - { - var pipe = new Pipe(); - var source = PipeSource.From(pipe.Reader); - - await pipe.Writer.WriteAsync(new byte[] { 1, 2, 3 }, CancellationToken.None); - await pipe.Writer.FlushAsync(CancellationToken.None); - await pipe.Writer.WriteAsync(new byte[] { 4, 5, 6 }, CancellationToken.None); - await pipe.Writer.FlushAsync(CancellationToken.None); - await pipe.Writer.WriteAsync(new byte[] { 7, 8, 9 }, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var result = await source - .RunWith(Sink.Seq>(), _materializer); - - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_handle_empty_buffer_with_is_completed_true() - { - var pipe = new Pipe(); - await pipe.Writer.CompleteAsync(); - - var source = PipeSource.From(pipe.Reader); - - var result = await source - .RunWith(Sink.Seq>(), _materializer); - - Assert.Empty(result); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_continue_reading_on_empty_buffer_with_is_completed_false() - { - var pipe = new Pipe(); - var source = PipeSource.From(pipe.Reader); - - var collectTask = source - .RunWith(Sink.Seq>(), _materializer); - - await Task.Delay(50, TestContext.Current.CancellationToken); - await pipe.Writer.WriteAsync(new byte[] { 1, 2, 3 }, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var result = await collectTask; - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(new byte[] { 1, 2, 3 }, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_handle_read_failure() - { - var pipe = new FailingPipeReader(); - var source = new PipeSourceStage(pipe); - - var error = await Assert.ThrowsAsync(async () => - { - await Source.FromGraph(source) - .RunWith(Sink.Seq>(), _materializer); - }); - - Assert.Equal("Read failed", error.Message); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_push_data_and_complete_when_is_completed_true() - { - var pipe = new Pipe(); - var source = PipeSource.From(pipe.Reader); - - var collectTask = source - .RunWith(Sink.Seq>(), _materializer); - - await pipe.Writer.WriteAsync(new byte[] { 10, 20, 30 }, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var result = await collectTask; - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(new byte[] { 10, 20, 30 }, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_handle_synchronous_read_completion() - { - var data = new byte[] { 99, 88, 77 }; - var pipe = new Pipe(); - - var writeTask = pipe.Writer.WriteAsync(data, CancellationToken.None); - await writeTask; - await pipe.Writer.CompleteAsync(); - - var source = PipeSource.From(pipe.Reader); - - var result = await source - .RunWith(Sink.Seq>(), _materializer); - - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(data, combined); - } - - [Fact(Timeout = 5000)] - public async Task Source_should_handle_asynchronous_read_completion() - { - var pipe = new Pipe(); - var source = PipeSource.From(pipe.Reader); - - var collectTask = source - .RunWith(Sink.Seq>(), _materializer); - - await Task.Delay(50, TestContext.Current.CancellationToken); - await pipe.Writer.WriteAsync(new byte[] { 50, 60 }, CancellationToken.None); - await Task.Delay(50, TestContext.Current.CancellationToken); - await pipe.Writer.WriteAsync(new byte[] { 70, 80 }, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var result = await collectTask; - var combined = result.SelectMany(m => m.ToArray()).ToArray(); - Assert.Equal(new byte[] { 50, 60, 70, 80 }, combined); - } - - private sealed class FailingPipeReader : PipeReader - { - public override void AdvanceTo(SequencePosition consumed) - { - } - - public override void AdvanceTo(SequencePosition consumed, SequencePosition examined) - { - } - - public override void CancelPendingRead() - { - } - - public override void Complete(Exception? exception = null) - { - } - - public override async ValueTask CompleteAsync(Exception? exception = null) - { - await ValueTask.CompletedTask; - } - - public override async ValueTask ReadAsync(CancellationToken cancellationToken = default) - { - await Task.Delay(10, cancellationToken); - throw new InvalidOperationException("Read failed"); - } - - public override bool TryRead(out ReadResult result) - { - result = default; - return false; - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Streams/IO/StreamSinkStageSpec.cs b/src/Servus.Akka.Tests/Streams/IO/StreamSinkStageSpec.cs deleted file mode 100644 index e192892d3..000000000 --- a/src/Servus.Akka.Tests/Streams/IO/StreamSinkStageSpec.cs +++ /dev/null @@ -1,220 +0,0 @@ -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.TestKit.Xunit; -using Servus.Akka.Streams.IO; - -namespace Servus.Akka.Tests.Streams.IO; - -public sealed class StreamSinkStageSpec : TestKit -{ - private readonly IMaterializer _materializer; - - public StreamSinkStageSpec() : base(ActorSystem.Create("test")) - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_write_single_chunk_to_stream() - { - var stream = new MemoryStream(); - var sink = StreamSink.To(stream); - - var data = new byte[] { 1, 2, 3, 4, 5 }; - await Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - Assert.Equal(data, stream.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_write_multiple_chunks_to_stream() - { - var stream = new MemoryStream(); - var sink = StreamSink.To(stream); - - var chunks = new[] - { - new byte[] { 1, 2, 3 }, - new byte[] { 4, 5, 6 }, - new byte[] { 7, 8, 9 } - }; - - var task = Source.From(chunks.Select(c => (ReadOnlyMemory)c.AsMemory())) - .RunWith(sink, _materializer); - - await task; - - Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, stream.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_skip_empty_chunks() - { - var stream = new MemoryStream(); - var sink = StreamSink.To(stream); - - var chunks = new[] - { - ReadOnlyMemory.Empty, - (ReadOnlyMemory)new byte[] { 1, 2, 3 }, - ReadOnlyMemory.Empty, - (ReadOnlyMemory)new byte[] { 4, 5 }, - ReadOnlyMemory.Empty - }; - - var task = Source.From(chunks) - .RunWith(sink, _materializer); - - await task; - - Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, stream.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_complete_task_when_upstream_finishes() - { - var stream = new MemoryStream(); - var sink = StreamSink.To(stream); - - var task = Source.Empty>() - .RunWith(sink, _materializer); - - await task; - - Assert.Empty(stream.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_fault_task_when_upstream_fails() - { - var stream = new MemoryStream(); - var sink = StreamSink.To(stream); - - var error = new InvalidOperationException("upstream failure"); - var task = Source.Failed>(error) - .RunWith(sink, _materializer); - - var ex = await Assert.ThrowsAsync(() => task); - Assert.Equal("upstream failure", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_synchronous_write_completion() - { - var stream = new SynchronousMemoryStream(); - var sink = StreamSink.To(stream); - - var data = new byte[] { 10, 20, 30 }; - await Source.Single((ReadOnlyMemory)data.AsMemory()) - .RunWith(sink, _materializer); - - Assert.Equal(data, stream.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_multiple_elements() - { - var stream = new MemoryStream(); - var sink = StreamSink.To(stream); - - var items = new[] - { - (ReadOnlyMemory)new byte[] { 40 }.AsMemory(), - (ReadOnlyMemory)new byte[] { 50 }.AsMemory(), - (ReadOnlyMemory)new byte[] { 60 }.AsMemory() - }; - - await Source.From(items).RunWith(sink, _materializer); - - Assert.Equal(new byte[] { 40, 50, 60 }, stream.ToArray()); - } - - [Fact(Timeout = 5000)] - public async Task Sink_should_handle_continuous_writes() - { - var stream = new MemoryStream(); - var sink = StreamSink.To(stream); - - var chunks = new[] - { - new byte[] { 1, 2 }, - new byte[] { 3, 4 }, - new byte[] { 5, 6 } - }; - - await Source.From(chunks.Select(c => (ReadOnlyMemory)c.AsMemory())) - .RunWith(sink, _materializer); - - Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, stream.ToArray()); - } - - private sealed class SynchronousMemoryStream : MemoryStream - { - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - Write(buffer.Span); - return default; - } - - public override Task FlushAsync(CancellationToken cancellationToken = default) - { - Flush(); - return Task.CompletedTask; - } - } - - private sealed class SlowMemoryStream : MemoryStream - { - private readonly int _delayMs; - - public SlowMemoryStream(int delayMs) - { - _delayMs = delayMs; - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - await Task.Delay(_delayMs, cancellationToken); - Write(buffer.Span); - } - - public override async Task FlushAsync(CancellationToken cancellationToken = default) - { - await Task.Delay(_delayMs, cancellationToken); - Flush(); - } - } - - private sealed class FailingMemoryStream : MemoryStream - { - private bool _failOnFirstWrite; - - public FailingMemoryStream(bool failOnFirstWrite) - { - _failOnFirstWrite = failOnFirstWrite; - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - if (_failOnFirstWrite) - { - _failOnFirstWrite = false; - return new ValueTask(Task.FromException(new InvalidOperationException("Write failed"))); - } - - Write(buffer.Span); - return default; - } - } - - private sealed class FailingFlushMemoryStream : MemoryStream - { - public override async Task FlushAsync(CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - throw new InvalidOperationException("Flush failed"); - } - } -} diff --git a/src/Servus.Akka.Tests/Transport/ConnectionInfoSpec.cs b/src/Servus.Akka.Tests/Transport/ConnectionInfoSpec.cs deleted file mode 100644 index 10c75a3e0..000000000 --- a/src/Servus.Akka.Tests/Transport/ConnectionInfoSpec.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Net; -using System.Net.Security; -using System.Security.Authentication; -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class ConnectionInfoSpec -{ - [Fact(Timeout = 5000)] - public void Should_store_endpoints_and_protocol() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - - var info = new ConnectionInfo(local, remote, TransportProtocol.Tcp); - - Assert.Equal(local, info.Local); - Assert.Equal(remote, info.Remote); - Assert.Equal(TransportProtocol.Tcp, info.Protocol); - Assert.Null(info.Security); - } - - [Fact(Timeout = 5000)] - public void Should_store_security_info_when_provided() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - var security = new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2); - - var info = new ConnectionInfo(local, remote, TransportProtocol.Tls, security); - - Assert.Equal(TransportProtocol.Tls, info.Protocol); - Assert.NotNull(info.Security); - Assert.Equal(SslProtocols.Tls13, info.Security.Protocol); - Assert.Equal(SslApplicationProtocol.Http2, info.Security.ApplicationProtocol); - } - - [Fact(Timeout = 5000)] - public void Equality_should_work_for_records() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - var security = new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2); - - var info1 = new ConnectionInfo(local, remote, TransportProtocol.Tls, security); - var info2 = new ConnectionInfo(local, remote, TransportProtocol.Tls, security); - - Assert.Equal(info1, info2); - Assert.Equal(info1.GetHashCode(), info2.GetHashCode()); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_local_endpoint() - { - var local1 = new IPEndPoint(IPAddress.Loopback, 5000); - var local2 = new IPEndPoint(IPAddress.Loopback, 5001); - var remote = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - - var info1 = new ConnectionInfo(local1, remote, TransportProtocol.Tcp); - var info2 = new ConnectionInfo(local2, remote, TransportProtocol.Tcp); - - Assert.NotEqual(info1, info2); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_remote_endpoint() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote1 = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - var remote2 = new IPEndPoint(IPAddress.Parse("192.168.1.2"), 443); - - var info1 = new ConnectionInfo(local, remote1, TransportProtocol.Tcp); - var info2 = new ConnectionInfo(local, remote2, TransportProtocol.Tcp); - - Assert.NotEqual(info1, info2); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_protocol() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - - var info1 = new ConnectionInfo(local, remote, TransportProtocol.Tcp); - var info2 = new ConnectionInfo(local, remote, TransportProtocol.Quic); - - Assert.NotEqual(info1, info2); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_security() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - - var info1 = new ConnectionInfo(local, remote, TransportProtocol.Tls, - new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2)); - var info2 = new ConnectionInfo(local, remote, TransportProtocol.Tls, - new SecurityInfo(SslProtocols.Tls12, SslApplicationProtocol.Http2)); - - Assert.NotEqual(info1, info2); - } - - [Fact(Timeout = 5000)] - public void None_should_have_sensible_defaults() - { - var none = ConnectionInfo.None; - - Assert.Equal(TransportProtocol.None, none.Protocol); - Assert.Null(none.Security); - } - - [Fact(Timeout = 5000)] - public void Should_work_as_dictionary_key() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote = new IPEndPoint(IPAddress.Parse("192.168.1.1"), 443); - - var info1 = new ConnectionInfo(local, remote, TransportProtocol.Tls, - new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2)); - var info2 = new ConnectionInfo(local, remote, TransportProtocol.Tls, - new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2)); - - var dict = new Dictionary { { info1, "pooled" } }; - - Assert.True(dict.ContainsKey(info2)); - Assert.Equal("pooled", dict[info2]); - } -} diff --git a/src/Servus.Akka.Tests/Transport/ListenerOptionsSpec.cs b/src/Servus.Akka.Tests/Transport/ListenerOptionsSpec.cs deleted file mode 100644 index 3941d082e..000000000 --- a/src/Servus.Akka.Tests/Transport/ListenerOptionsSpec.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class ListenerOptionsSpec -{ - private static X509Certificate2 CreateDummyCert() - { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("cn=test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1)); - } - - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_have_correct_defaults() - { - var options = new TcpListenerOptions - { - Host = "localhost", - Port = 8080 - }; - - Assert.True(options.ReuseAddress); - Assert.True(options.NoDelay); - Assert.Equal(int.MaxValue, options.Backlog); - Assert.Null(options.ServerCertificate); - Assert.Equal(SslProtocols.None, options.EnabledSslProtocols); - } - - [Fact(Timeout = 5000)] - public void QuicListenerOptions_should_have_correct_defaults() - { - var cert = CreateDummyCert(); - var protocols = new List { SslApplicationProtocol.Http3 }; - - var options = new QuicListenerOptions - { - Host = "localhost", - Port = 443, - ServerCertificate = cert, - ApplicationProtocols = protocols - }; - - Assert.Equal(100, options.MaxInboundBidirectionalStreams); - Assert.Equal(3, options.MaxInboundUnidirectionalStreams); - Assert.Equal(TimeSpan.FromSeconds(30), options.IdleTimeout); - Assert.Equal(SslProtocols.None, options.EnabledSslProtocols); - } - - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_allow_property_override() - { - var options = new TcpListenerOptions - { - Host = "0.0.0.0", - Port = 9000, - ReuseAddress = false, - NoDelay = false, - Backlog = 256, - SocketSendBufferSize = 65536, - SocketReceiveBufferSize = 65536 - }; - - Assert.Equal("0.0.0.0", options.Host); - Assert.Equal(9000, options.Port); - Assert.False(options.ReuseAddress); - Assert.False(options.NoDelay); - Assert.Equal(256, options.Backlog); - Assert.Equal(65536, options.SocketSendBufferSize); - Assert.Equal(65536, options.SocketReceiveBufferSize); - } - - [Fact(Timeout = 5000)] - public void QuicListenerOptions_should_allow_property_override() - { - var cert = CreateDummyCert(); - var protocols = new List { SslApplicationProtocol.Http3 }; - - var options = new QuicListenerOptions - { - Host = "0.0.0.0", - Port = 443, - MaxInboundBidirectionalStreams = 200, - MaxInboundUnidirectionalStreams = 10, - IdleTimeout = TimeSpan.FromSeconds(60), - ServerCertificate = cert, - ApplicationProtocols = protocols, - Backlog = 512 - }; - - Assert.Equal("0.0.0.0", options.Host); - Assert.Equal(443, options.Port); - Assert.Equal(200, options.MaxInboundBidirectionalStreams); - Assert.Equal(10, options.MaxInboundUnidirectionalStreams); - Assert.Equal(TimeSpan.FromSeconds(60), options.IdleTimeout); - Assert.Equal(512, options.Backlog); - Assert.Same(cert, options.ServerCertificate); - Assert.Same(protocols, options.ApplicationProtocols); - } - - [Fact(Timeout = 5000)] - public void ListenerOptions_base_should_have_default_backlog_128() - { - var options = new TcpListenerOptions - { - Host = "127.0.0.1", - Port = 0 - }; - - Assert.Equal(int.MaxValue, options.Backlog); - } - - [Fact(Timeout = 5000)] - public void ListenerOptions_base_should_have_null_socket_buffer_sizes() - { - var options = new TcpListenerOptions - { - Host = "127.0.0.1", - Port = 0 - }; - - Assert.Null(options.SocketSendBufferSize); - Assert.Null(options.SocketReceiveBufferSize); - } - - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_have_null_certificate_by_default() - { - var options = new TcpListenerOptions - { - Host = "127.0.0.1", - Port = 0 - }; - - Assert.Null(options.ServerCertificate); - } - - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_have_null_application_protocols() - { - var options = new TcpListenerOptions - { - Host = "127.0.0.1", - Port = 0 - }; - - Assert.Null(options.ApplicationProtocols); - } - - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_have_null_client_cert_callback() - { - var options = new TcpListenerOptions - { - Host = "127.0.0.1", - Port = 0 - }; - - Assert.Null(options.ClientCertificateValidationCallback); - } - - [Fact(Timeout = 5000)] - public void QuicListenerOptions_should_have_null_client_cert_callback() - { - var cert = CreateDummyCert(); - var protocols = new List { SslApplicationProtocol.Http3 }; - - var options = new QuicListenerOptions - { - Host = "127.0.0.1", - Port = 0, - ServerCertificate = cert, - ApplicationProtocols = protocols - }; - - Assert.Null(options.ClientCertificateValidationCallback); - } - - [Fact(Timeout = 5000)] - public void QuicListenerOptions_should_have_ssl_protocols_none_by_default() - { - var cert = CreateDummyCert(); - var protocols = new List { SslApplicationProtocol.Http3 }; - - var options = new QuicListenerOptions - { - Host = "127.0.0.1", - Port = 0, - ServerCertificate = cert, - ApplicationProtocols = protocols - }; - - Assert.Equal(SslProtocols.None, options.EnabledSslProtocols); - } -} diff --git a/src/Servus.Akka.Tests/Transport/MultiplexedMessagesSpec.cs b/src/Servus.Akka.Tests/Transport/MultiplexedMessagesSpec.cs deleted file mode 100644 index 06d2b9b0d..000000000 --- a/src/Servus.Akka.Tests/Transport/MultiplexedMessagesSpec.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class MultiplexedMessagesSpec -{ - [Fact(Timeout = 5000)] - public void OpenStream_should_implement_ITransportOutbound() - { - ITransportOutbound msg = new OpenStream(42, StreamDirection.Bidirectional); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void OpenStream_should_carry_stream_id_and_direction() - { - var msg = new OpenStream(7, StreamDirection.Unidirectional); - - Assert.Equal(new StreamTarget(7), msg.StreamId); - Assert.Equal(StreamDirection.Unidirectional, msg.Direction); - } - - [Fact(Timeout = 5000)] - public void CloseStream_should_implement_ITransportOutbound() - { - ITransportOutbound msg = new CloseStream(99); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void CloseStream_should_carry_stream_id() - { - var msg = new CloseStream(55); - - Assert.Equal(new StreamTarget(55), msg.StreamId); - } - - [Fact(Timeout = 5000)] - public void StreamOpened_should_implement_ITransportInbound() - { - ITransportInbound msg = new StreamOpened(1, StreamDirection.Bidirectional); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void StreamOpened_should_carry_stream_id_and_direction() - { - var msg = new StreamOpened(3, StreamDirection.Unidirectional); - - Assert.Equal(new StreamTarget(3), msg.Id); - Assert.Equal(StreamDirection.Unidirectional, msg.Direction); - } - - [Fact(Timeout = 5000)] - public void StreamClosed_should_implement_ITransportInbound() - { - ITransportInbound msg = new StreamClosed(10, DisconnectReason.Graceful); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void StreamClosed_should_carry_stream_id_and_reason() - { - var msg = new StreamClosed(22, DisconnectReason.Error); - - Assert.Equal(new StreamTarget(22), msg.Id); - Assert.Equal(DisconnectReason.Error, msg.Reason); - } - - [Fact(Timeout = 5000)] - public void CompleteWrites_should_implement_ITransportOutbound() - { - ITransportOutbound msg = new CompleteWrites(42); - var cw = Assert.IsType(msg); - Assert.Equal(new StreamTarget(42), cw.StreamId); - } - - [Fact(Timeout = 5000)] - public void ResetStream_should_implement_ITransportOutbound() - { - ITransportOutbound msg = new ResetStream(7, 0x0104); - var rs = Assert.IsType(msg); - Assert.Equal(new StreamTarget(7), rs.StreamId); - Assert.Equal(0x0104, rs.ErrorCode); - } - - [Fact(Timeout = 5000)] - public void ServerStreamAccepted_should_implement_ITransportInbound() - { - ITransportInbound msg = new ServerStreamAccepted(3, StreamDirection.Unidirectional); - var ssa = Assert.IsType(msg); - Assert.Equal(new StreamTarget(3), ssa.Id); - Assert.Equal(StreamDirection.Unidirectional, ssa.Direction); - } - - [Fact(Timeout = 5000)] - public void StreamReadCompleted_should_implement_ITransportInbound() - { - ITransportInbound msg = new StreamReadCompleted(0); - var src = Assert.IsType(msg); - Assert.Equal(new StreamTarget(0), src.Id); - } -} diff --git a/src/Servus.Akka.Tests/Transport/PipeModeSpec.cs b/src/Servus.Akka.Tests/Transport/PipeModeSpec.cs deleted file mode 100644 index 087235b91..000000000 --- a/src/Servus.Akka.Tests/Transport/PipeModeSpec.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class PipeModeSpec -{ - [Fact(Timeout = 5000)] - public void PipeMode_should_have_three_values() - { - var values = Enum.GetValues(); - Assert.Equal(3, values.Length); - } - - [Theory(Timeout = 5000)] - [InlineData(0, 0)] - [InlineData(1, 1)] - [InlineData(2, 2)] - public void PipeMode_should_have_correct_ordinal(int modeValue, int expected) - { - var mode = (PipeMode)modeValue; - Assert.Equal(expected, (int)mode); - } -} diff --git a/src/Servus.Akka.Tests/Transport/PoolConfigRegistrySpec.cs b/src/Servus.Akka.Tests/Transport/PoolConfigRegistrySpec.cs deleted file mode 100644 index d91576d60..000000000 --- a/src/Servus.Akka.Tests/Transport/PoolConfigRegistrySpec.cs +++ /dev/null @@ -1,212 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class PoolConfigRegistrySpec -{ - [Fact(Timeout = 5000)] - public void Constructor_should_set_default_config() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var registry = new PoolConfigRegistry(defaultConfig); - - var resolved = registry.Resolve(null); - Assert.Equal(defaultConfig, resolved); - } - - [Fact(Timeout = 5000)] - public void Resolve_should_return_default_when_key_is_null() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 5, - IdleTimeout: TimeSpan.FromSeconds(60), - ConnectionLifetime: TimeSpan.FromMinutes(10), - ReuseOnUpstreamFinish: false); - - var registry = new PoolConfigRegistry(defaultConfig); - - var resolved = registry.Resolve(null); - Assert.Equal(defaultConfig, resolved); - } - - [Fact(Timeout = 5000)] - public void Resolve_should_return_default_when_key_not_registered() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 8, - IdleTimeout: TimeSpan.FromSeconds(45), - ConnectionLifetime: TimeSpan.FromMinutes(3), - ReuseOnUpstreamFinish: true); - - var registry = new PoolConfigRegistry(defaultConfig); - - var resolved = registry.Resolve("nonexistent-pool"); - Assert.Equal(defaultConfig, resolved); - } - - [Fact(Timeout = 5000)] - public void Register_should_store_config_for_key() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var customConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 20, - IdleTimeout: TimeSpan.FromSeconds(15), - ConnectionLifetime: TimeSpan.FromMinutes(2), - ReuseOnUpstreamFinish: false); - - var registry = new PoolConfigRegistry(defaultConfig); - registry.Register("custom-pool", customConfig); - - var resolved = registry.Resolve("custom-pool"); - Assert.Equal(customConfig, resolved); - } - - [Fact(Timeout = 5000)] - public void Resolve_should_return_registered_config() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var poolAConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 5, - IdleTimeout: TimeSpan.FromSeconds(20), - ConnectionLifetime: TimeSpan.FromMinutes(1), - ReuseOnUpstreamFinish: false); - - var poolBConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 50, - IdleTimeout: TimeSpan.FromSeconds(60), - ConnectionLifetime: TimeSpan.FromMinutes(10), - ReuseOnUpstreamFinish: true); - - var registry = new PoolConfigRegistry(defaultConfig); - registry.Register("pool-a", poolAConfig); - registry.Register("pool-b", poolBConfig); - - Assert.Equal(poolAConfig, registry.Resolve("pool-a")); - Assert.Equal(poolBConfig, registry.Resolve("pool-b")); - Assert.Equal(defaultConfig, registry.Resolve("pool-c")); - } - - [Fact(Timeout = 5000)] - public void Register_should_overwrite_existing_key() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var initialConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 15, - IdleTimeout: TimeSpan.FromSeconds(25), - ConnectionLifetime: TimeSpan.FromMinutes(3), - ReuseOnUpstreamFinish: false); - - var overwriteConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 25, - IdleTimeout: TimeSpan.FromSeconds(40), - ConnectionLifetime: TimeSpan.FromMinutes(7), - ReuseOnUpstreamFinish: true); - - var registry = new PoolConfigRegistry(defaultConfig); - registry.Register("pool", initialConfig); - - var resolved1 = registry.Resolve("pool"); - Assert.Equal(initialConfig, resolved1); - - registry.Register("pool", overwriteConfig); - - var resolved2 = registry.Resolve("pool"); - Assert.Equal(overwriteConfig, resolved2); - } - - [Fact(Timeout = 5000)] - public void Register_should_throw_if_config_is_null() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var registry = new PoolConfigRegistry(defaultConfig); - - Assert.Throws(() => registry.Register("pool", null!)); - } - - [Fact(Timeout = 5000)] - public void Constructor_should_throw_if_default_config_is_null() - { - Assert.Throws(() => new PoolConfigRegistry(null!)); - } - - [Fact(Timeout = 5000)] - public void Register_should_support_case_insensitive_keys() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var customConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 20, - IdleTimeout: TimeSpan.FromSeconds(15), - ConnectionLifetime: TimeSpan.FromMinutes(2), - ReuseOnUpstreamFinish: false); - - var registry = new PoolConfigRegistry(defaultConfig); - registry.Register("MyPool", customConfig); - - var resolved1 = registry.Resolve("mypool"); - var resolved2 = registry.Resolve("MYPOOL"); - - Assert.Equal(customConfig, resolved1); - Assert.Equal(customConfig, resolved2); - } - - [Fact(Timeout = 5000)] - public void Register_should_return_self_for_fluent_chaining() - { - var defaultConfig = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 5, - IdleTimeout: TimeSpan.FromSeconds(20), - ConnectionLifetime: TimeSpan.FromMinutes(1), - ReuseOnUpstreamFinish: false); - - var config2 = new TcpPoolConfig( - MaxConnectionsPerHost: 15, - IdleTimeout: TimeSpan.FromSeconds(40), - ConnectionLifetime: TimeSpan.FromMinutes(4), - ReuseOnUpstreamFinish: true); - - var registry = new PoolConfigRegistry(defaultConfig); - var result = registry - .Register("pool1", config1) - .Register("pool2", config2); - - Assert.Same(registry, result); - Assert.Equal(config1, registry.Resolve("pool1")); - Assert.Equal(config2, registry.Resolve("pool2")); - } -} diff --git a/src/Servus.Akka.Tests/Transport/PoolingStrategySpec.cs b/src/Servus.Akka.Tests/Transport/PoolingStrategySpec.cs deleted file mode 100644 index c12619e02..000000000 --- a/src/Servus.Akka.Tests/Transport/PoolingStrategySpec.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class PoolingStrategySpec -{ - [Fact(Timeout = 5000)] - public void NoReuse_should_return_Dispose_on_disconnect() - { - var strategy = new NoReuseStrategy(); - - Assert.Equal(PoolAction.Dispose, strategy.OnDisconnect(new object(), DisconnectReason.Graceful)); - } - - [Fact(Timeout = 5000)] - public void NoReuse_should_return_Dispose_on_upstream_finish() - { - var strategy = new NoReuseStrategy(); - - Assert.Equal(PoolAction.Dispose, strategy.OnUpstreamFinish(new object())); - } - - [Fact(Timeout = 5000)] - public void Reuse_should_return_Dispose_on_disconnect() - { - var strategy = new ReuseStrategy(); - - Assert.Equal(PoolAction.Dispose, strategy.OnDisconnect(new object(), DisconnectReason.Error)); - } - - [Fact(Timeout = 5000)] - public void Reuse_should_return_Reuse_on_upstream_finish() - { - var strategy = new ReuseStrategy(); - - Assert.Equal(PoolAction.Reuse, strategy.OnUpstreamFinish(new object())); - } - - private sealed class NoReuseStrategy : IPoolingStrategy - { - public PoolAction OnDisconnect(object lease, DisconnectReason reason) => PoolAction.Dispose; - public PoolAction OnUpstreamFinish(object lease) => PoolAction.Dispose; - } - - private sealed class ReuseStrategy : IPoolingStrategy - { - public PoolAction OnDisconnect(object lease, DisconnectReason reason) => PoolAction.Dispose; - public PoolAction OnUpstreamFinish(object lease) => PoolAction.Reuse; - } -} diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicClientProviderSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicClientProviderSpec.cs deleted file mode 100644 index 4019043f2..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicClientProviderSpec.cs +++ /dev/null @@ -1,601 +0,0 @@ -using System.Net.Quic; -using System.Net.Security; -using System.Security.Authentication; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -[Collection("ClientProvider")] -public sealed class QuicClientProviderSpec -{ - private static async Task GetStreamOrSkipAsync(QuicClientProvider provider, CancellationToken ct) - { - try - { - return await provider.GetStreamAsync(ct); - } - catch (AuthenticationException ex) - { - Assert.Skip(string.Concat("QUIC ALPN not available: ", ex.Message)); - return null!; - } - } - - private static async Task GetUnidirectionalStreamOrSkipAsync(QuicClientProvider provider, CancellationToken ct) - { - try - { - return await provider.GetUnidirectionalStreamAsync(ct); - } - catch (AuthenticationException ex) - { - Assert.Skip(string.Concat("QUIC ALPN not available: ", ex.Message)); - return null!; - } - } - - private static async Task ConnectOrSkipAsync(QuicClientProvider provider, CancellationToken ct) - { - try - { - await provider.ConnectAsync(ct); - } - catch (AuthenticationException ex) - { - Assert.Skip(string.Concat("QUIC ALPN not available: ", ex.Message)); - } - } - - [Fact(Timeout = 15000)] - public async Task GetStreamAsync_should_return_bidirectional_stream() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - var conn = await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - var stream = await conn.AcceptInboundStreamAsync(TestContext.Current.CancellationToken); - await stream.WriteAsync(new byte[] { 42 }, TestContext.Current.CancellationToken); - stream.CompleteWrites(); - return conn; - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - var stream = await GetStreamOrSkipAsync(provider, TestContext.Current.CancellationToken); - Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); - - await stream.DisposeAsync(); - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 15000)] - public async Task GetUnidirectionalStreamAsync_should_return_unidirectional_stream() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - return await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - var stream = await GetUnidirectionalStreamOrSkipAsync(provider, TestContext.Current.CancellationToken); - Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); - - await stream.DisposeAsync(); - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 15000)] - public async Task AcceptInboundStreamAsync_should_accept_inbound_stream() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var serverReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var acceptTask = Task.Run(async () => - { - try - { - var conn = await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - serverReady.SetResult(); - var stream = await conn.OpenOutboundStreamAsync(QuicStreamType.Bidirectional, TestContext.Current.CancellationToken); - await stream.WriteAsync(new byte[] { 42 }, TestContext.Current.CancellationToken); - stream.CompleteWrites(); - return conn; - } - catch - { - serverReady.TrySetResult(); - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true, - MaxBidirectionalStreams = 10 - }; - - var provider = new QuicClientProvider(options); - - try - { - await ConnectOrSkipAsync(provider, TestContext.Current.CancellationToken); - await serverReady.Task; - var stream = await provider.AcceptInboundStreamAsync(TestContext.Current.CancellationToken); - Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); - - await stream.DisposeAsync(); - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 15000)] - public async Task EnsureConnectedAsync_should_reuse_connection() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - var conn = await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - var stream1 = await conn.AcceptInboundStreamAsync(TestContext.Current.CancellationToken); - var stream2 = await conn.AcceptInboundStreamAsync(TestContext.Current.CancellationToken); - await stream1.WriteAsync(new byte[] { 42 }, TestContext.Current.CancellationToken); - await stream2.WriteAsync(new byte[] { 43 }, TestContext.Current.CancellationToken); - stream1.CompleteWrites(); - stream2.CompleteWrites(); - return conn; - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - var stream1 = await GetStreamOrSkipAsync(provider, TestContext.Current.CancellationToken); - var stream2 = await provider.GetStreamAsync(TestContext.Current.CancellationToken); - - Assert.NotNull(stream1); - Assert.NotNull(stream2); - - await stream1.DisposeAsync(); - await stream2.DisposeAsync(); - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 5000)] - public async Task EnsureConnectedAsync_should_throw_on_empty_host() - { - var protocolList = new List { LoopbackQuicServer.Alpn }; - var options = new QuicTransportOptions - { - Host = "", - Port = 443, - ApplicationProtocols = protocolList, - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - await Assert.ThrowsAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - } - finally - { - await provider.DisposeAsync(); - } - } - - [Fact(Timeout = 5000)] - public async Task EnsureConnectedAsync_should_throw_on_null_host() - { - var protocolList = new List { LoopbackQuicServer.Alpn }; - var options = new QuicTransportOptions - { - Host = null!, - Port = 443, - ApplicationProtocols = protocolList, - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - await Assert.ThrowsAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - } - finally - { - await provider.DisposeAsync(); - } - } - - [Fact(Timeout = 15000)] - public async Task DisposeAsync_should_close_connection() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - return await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - var stream = await GetStreamOrSkipAsync(provider, TestContext.Current.CancellationToken); - Assert.NotNull(stream); - await stream.DisposeAsync(); - - await provider.DisposeAsync(); - - await Assert.ThrowsAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 15000)] - public async Task DisposeAsync_should_be_idempotent() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - return await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - await GetStreamOrSkipAsync(provider, TestContext.Current.CancellationToken); - - await provider.DisposeAsync(); - await provider.DisposeAsync(); - await provider.DisposeAsync(); - } - finally - { - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 15000)] - public async Task LocalEndPoint_should_be_set_after_connection() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - return await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - Assert.Null(provider.LocalEndPoint); - - await GetStreamOrSkipAsync(provider, TestContext.Current.CancellationToken); - - Assert.NotNull(provider.LocalEndPoint); - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 15000)] - public async Task ConnectAsync_should_establish_connection_on_demand() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - return await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - Assert.Null(provider.LocalEndPoint); - - await ConnectOrSkipAsync(provider, TestContext.Current.CancellationToken); - - Assert.NotNull(provider.LocalEndPoint); - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } - - [Fact(Timeout = 15000)] - public async Task GetStreamAsync_should_handle_concurrent_requests() - { - if (!QuicListener.IsSupported) - { - return; - } - - var server = await LoopbackQuicServer.CreateAsync(); - var acceptTask = Task.Run(async () => - { - try - { - var conn = await server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - for (var i = 0; i < 5; i++) - { - var stream = await conn.AcceptInboundStreamAsync(TestContext.Current.CancellationToken); - await stream.WriteAsync(new[] { (byte)i }, TestContext.Current.CancellationToken); - stream.CompleteWrites(); - } - - return conn; - } - catch - { - return null; - } - }); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new QuicClientProvider(options); - - try - { - var first = await GetStreamOrSkipAsync(provider, TestContext.Current.CancellationToken); - var tasks = Enumerable.Range(0, 4) - .Select(async _ => await provider.GetStreamAsync(TestContext.Current.CancellationToken)) - .ToList(); - - var streams = new[] { first }.Concat(await Task.WhenAll(tasks)).ToArray(); - - Assert.Equal(5, streams.Length); - foreach (var stream in streams) - { - Assert.NotNull(stream); - await stream.DisposeAsync(); - } - } - finally - { - await provider.DisposeAsync(); - await server.DisposeAsync(); - var serverConn = await acceptTask; - if (serverConn is not null) - { - await serverConn.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionFactorySpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionFactorySpec.cs deleted file mode 100644 index 9c54aa51c..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionFactorySpec.cs +++ /dev/null @@ -1,307 +0,0 @@ -using System.Net.Quic; -using System.Security.Authentication; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -public sealed class QuicConnectionFactorySpec -{ - private static async Task TryEstablishAsync(QuicTransportOptions options, - CancellationToken ct) - { - try - { - return await QuicConnectionFactory.Instance.EstablishAsync(options, ct); - } - catch (AuthenticationException ex) - { - Assert.Skip(string.Concat("QUIC ALPN not available: ", ex.Message)); - return null; - } - } - - [Fact(Timeout = 15000)] - public async Task EstablishAsync_should_return_lease_with_valid_handle() - { - if (!QuicListener.IsSupported) - { - return; - } - - await using var server = await LoopbackQuicServer.CreateAsync(); - var serverConnTask = server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true, - IdleTimeout = TimeSpan.FromSeconds(5), - MaxBidirectionalStreams = 10, - MaxUnidirectionalStreams = 5 - }; - - var lease = await TryEstablishAsync(options, TestContext.Current.CancellationToken); - if (lease is null) - { - return; - } - - Assert.NotNull(lease.Handle); - Assert.True(lease.IsAlive()); - Assert.Equal(0, lease.ActiveStreams); - - await lease.DisposeAsync(); - try - { - var serverConn = await serverConnTask; - await serverConn.DisposeAsync(); - } - catch - { - // Server connection acceptance may fail if client closes first - } - } - - [Fact(Timeout = 15000)] - public async Task EstablishAsync_should_create_bidirectional_streams() - { - if (!QuicListener.IsSupported) - { - return; - } - - await using var server = await LoopbackQuicServer.CreateAsync(); - var serverConnTask = server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var lease = await TryEstablishAsync(options, TestContext.Current.CancellationToken); - if (lease is null) - { - return; - } - - var (stream, streamId) = - await lease.Handle.OpenStreamAsync(StreamDirection.Bidirectional, TestContext.Current.CancellationToken); - Assert.NotNull(stream); - Assert.True(streamId >= 0, "Stream ID should be non-negative"); - - await stream.DisposeAsync(); - await lease.DisposeAsync(); - try - { - var serverConn = await serverConnTask; - await serverConn.DisposeAsync(); - } - catch - { - // Server connection acceptance may fail if client closes first - } - } - - [Fact(Timeout = 15000)] - public async Task EstablishAsync_should_create_unidirectional_streams() - { - if (!QuicListener.IsSupported) - { - return; - } - - await using var server = await LoopbackQuicServer.CreateAsync(); - var serverConnTask = server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var lease = await TryEstablishAsync(options, TestContext.Current.CancellationToken); - if (lease is null) - { - return; - } - - var (stream, streamId) = - await lease.Handle.OpenStreamAsync(StreamDirection.Unidirectional, TestContext.Current.CancellationToken); - Assert.NotNull(stream); - Assert.True(streamId >= 0, "Stream ID should be non-negative"); - - await stream.DisposeAsync(); - await lease.DisposeAsync(); - try - { - var serverConn = await serverConnTask; - await serverConn.DisposeAsync(); - } - catch - { - // Server connection acceptance may fail if client closes first - } - } - - [Fact(Timeout = 5000)] - public async Task EstablishAsync_should_throw_on_invalid_host() - { - if (!QuicListener.IsSupported) - { - return; - } - - var options = new QuicTransportOptions - { - Host = "invalid-host-that-does-not-exist-12345.com", - Port = 443, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - await Assert.ThrowsAsync(() => - QuicConnectionFactory.Instance.EstablishAsync(options, TestContext.Current.CancellationToken)); - } - - [Fact(Timeout = 15000)] - public async Task EstablishAsync_should_dispose_cleanly() - { - if (!QuicListener.IsSupported) - { - return; - } - - await using var server = await LoopbackQuicServer.CreateAsync(); - var serverConnTask = server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var lease = await TryEstablishAsync(options, TestContext.Current.CancellationToken); - if (lease is null) - { - return; - } - - Assert.True(lease.IsAlive()); - - await lease.DisposeAsync(); - Assert.False(lease.IsAlive()); - - try - { - var serverConn = await serverConnTask; - await serverConn.DisposeAsync(); - } - catch - { - // Server connection acceptance may fail if client closes first - } - } - - [Fact(Timeout = 15000)] - public async Task EstablishAsync_should_track_active_streams() - { - if (!QuicListener.IsSupported) - { - return; - } - - await using var server = await LoopbackQuicServer.CreateAsync(); - var serverConnTask = server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true, - MaxBidirectionalStreams = 10 - }; - - var lease = await TryEstablishAsync(options, TestContext.Current.CancellationToken); - if (lease is null) - { - return; - } - - Assert.Equal(0, lease.ActiveStreams); - - lease.MarkBusy(); - Assert.Equal(1, lease.ActiveStreams); - - lease.MarkBusy(); - Assert.Equal(2, lease.ActiveStreams); - - lease.MarkIdle(); - Assert.Equal(1, lease.ActiveStreams); - - lease.MarkIdle(); - Assert.Equal(0, lease.ActiveStreams); - - await lease.DisposeAsync(); - try - { - var serverConn = await serverConnTask; - await serverConn.DisposeAsync(); - } - catch - { - // Server connection acceptance may fail if client closes first - } - } - - [Fact(Timeout = 15000)] - public async Task EstablishAsync_should_return_valid_local_endpoint() - { - if (!QuicListener.IsSupported) - { - return; - } - - await using var server = await LoopbackQuicServer.CreateAsync(); - var serverConnTask = server.AcceptConnectionAsync(TestContext.Current.CancellationToken); - - var options = new QuicTransportOptions - { - Host = "localhost", - Port = (ushort)server.Port, - ApplicationProtocols = [LoopbackQuicServer.Alpn], - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var lease = await TryEstablishAsync(options, TestContext.Current.CancellationToken); - if (lease is null) - { - return; - } - - var localEndPoint = lease.Handle.LocalEndPoint(); - Assert.NotNull(localEndPoint); - - await lease.DisposeAsync(); - try - { - var serverConn = await serverConnTask; - await serverConn.DisposeAsync(); - } - catch - { - // Server connection acceptance may fail if client closes first - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionLeaseSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionLeaseSpec.cs deleted file mode 100644 index 03b1f2ee7..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionLeaseSpec.cs +++ /dev/null @@ -1,269 +0,0 @@ -using Servus.Akka.Transport.Quic; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -public sealed class QuicConnectionLeaseSpec -{ - private QuicConnectionHandle CreateTestHandle() => - new( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - [Fact(Timeout = 5000)] - public void Handle_should_return_constructor_value() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - Assert.Same(handle, lease.Handle); - } - - [Fact(Timeout = 5000)] - public void IsAlive_should_return_true_initially() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - Assert.True(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public void IsExpired_should_return_false_when_within_lifetime() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - Assert.False(lease.IsExpired(TimeSpan.FromSeconds(10))); - } - - [Fact(Timeout = 5000)] - public async Task IsExpired_should_return_true_when_past_lifetime() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - // Create with short lifetime - var shortLifetime = TimeSpan.FromMilliseconds(50); - - // Wait longer than the lifetime - await Task.Delay(100, TestContext.Current.CancellationToken); - - Assert.True(lease.IsExpired(shortLifetime)); - } - - [Fact(Timeout = 5000)] - public void IsExpired_should_return_false_for_infinite_lifetime() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - // Infinite lifetime should never expire - Assert.False(lease.IsExpired(Timeout.InfiniteTimeSpan)); - } - - [Fact(Timeout = 5000)] - public void CanAcceptStream_should_return_true_when_below_max() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 5); - - // Initially no active streams, should accept - Assert.True(lease.CanAcceptStream()); - - // Mark busy twice - lease.MarkBusy(); - lease.MarkBusy(); - - // Still below max (2 < 5) - Assert.True(lease.CanAcceptStream()); - } - - [Fact(Timeout = 5000)] - public void CanAcceptStream_should_return_false_when_at_max() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 3); - - // Mark busy up to max - lease.MarkBusy(); - lease.MarkBusy(); - lease.MarkBusy(); - - // At max, should not accept - Assert.False(lease.CanAcceptStream()); - } - - [Fact(Timeout = 5000)] - public void CanAcceptStream_should_return_false_when_not_alive() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 5); - - // Dispose to mark as not alive - _ = lease.DisposeAsync(); - - Assert.False(lease.IsAlive()); - Assert.False(lease.CanAcceptStream()); - } - - [Fact(Timeout = 5000)] - public void MarkBusy_should_increment_ActiveStreams() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - Assert.Equal(0, lease.ActiveStreams); - - lease.MarkBusy(); - Assert.Equal(1, lease.ActiveStreams); - - lease.MarkBusy(); - Assert.Equal(2, lease.ActiveStreams); - } - - [Fact(Timeout = 5000)] - public void MarkIdle_should_decrement_ActiveStreams() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - lease.MarkBusy(); - lease.MarkBusy(); - lease.MarkBusy(); - - Assert.Equal(3, lease.ActiveStreams); - - lease.MarkIdle(); - Assert.Equal(2, lease.ActiveStreams); - - lease.MarkIdle(); - Assert.Equal(1, lease.ActiveStreams); - } - - [Fact(Timeout = 5000)] - public void MarkIdle_should_not_go_below_zero() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - // Start at 0 - Assert.Equal(0, lease.ActiveStreams); - - // Decrement - lease.MarkIdle(); - - // Should be -1 (no guard in production code) - Assert.Equal(-1, lease.ActiveStreams); - } - - [Fact(Timeout = 5000)] - public void ActiveStreams_should_reflect_busy_idle_balance() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - lease.MarkBusy(); - lease.MarkBusy(); - lease.MarkBusy(); - Assert.Equal(3, lease.ActiveStreams); - - lease.MarkIdle(); - Assert.Equal(2, lease.ActiveStreams); - - lease.MarkBusy(); - Assert.Equal(3, lease.ActiveStreams); - - lease.MarkIdle(); - lease.MarkIdle(); - Assert.Equal(1, lease.ActiveStreams); - } - - [Fact(Timeout = 5000)] - public void LastActivity_should_update_on_MarkBusy() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - var initialActivity = lease.LastActivity; - - // Wait a bit to ensure time difference - Thread.Sleep(10); - - lease.MarkBusy(); - var afterBusy = lease.LastActivity; - - Assert.True(afterBusy > initialActivity); - } - - [Fact(Timeout = 5000)] - public void LastActivity_should_update_on_MarkIdle() - { - var handle = CreateTestHandle(); - var lease = new QuicConnectionLease(handle, 10); - - lease.MarkBusy(); - var afterBusy = lease.LastActivity; - - Thread.Sleep(10); - - lease.MarkIdle(); - var afterIdle = lease.LastActivity; - - Assert.True(afterIdle > afterBusy); - } - - [Fact(Timeout = 5000)] - public async Task DisposeAsync_should_dispose_handle() - { - var disposeCalled = false; - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => - { - disposeCalled = true; - return ValueTask.CompletedTask; - }); - - var lease = new QuicConnectionLease(handle, 10); - - Assert.True(lease.IsAlive()); - Assert.False(disposeCalled); - - await lease.DisposeAsync(); - - Assert.False(lease.IsAlive()); - Assert.True(disposeCalled); - } - - [Fact(Timeout = 5000)] - public async Task DisposeAsync_should_be_idempotent() - { - var disposeCount = 0; - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => - { - disposeCount++; - return ValueTask.CompletedTask; - }); - - var lease = new QuicConnectionLease(handle, 10); - - await lease.DisposeAsync(); - Assert.Equal(1, disposeCount); - - // Second dispose should not call handle.DisposeAsync again - await lease.DisposeAsync(); - Assert.Equal(1, disposeCount); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionManagerActorSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionManagerActorSpec.cs deleted file mode 100644 index b2a1eafb6..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionManagerActorSpec.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Akka.Actor; -using Akka.TestKit.Xunit; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -public sealed class QuicConnectionManagerActorSpec : TestKit -{ - private static QuicTransportOptions CreateOptions() => new() - { - Host = "localhost", - Port = 443 - }; - - private static IQuicConnectionFactory CreateMockFactory(bool shouldFail = false, int maxStreams = 100) - { - return new MockFactory(shouldFail, maxStreams); - } - - private IActorRef CreateActor(IQuicConnectionFactory? factory = null) - { - var f = factory ?? CreateMockFactory(); - return Sys.ActorOf(TransportFactory.CreateQuicConnectionManager(f)); - } - - [Fact(Timeout = 5000)] - public async Task AcquireAsync_should_return_lease() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - - await lease.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task AcquireAsync_should_call_factory_EstablishAsync() - { - var factory = new MockFactory(); - var actor = CreateActor(factory); - var options = CreateOptions(); - - Assert.Equal(0, factory.EstablishCount); - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.Equal(1, factory.EstablishCount); - - await lease.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task AcquireAsync_should_fail_when_factory_throws() - { - var factory = new MockFactory(shouldFail: true); - var actor = CreateActor(factory); - var options = CreateOptions(); - - await Assert.ThrowsAnyAsync(() => - QuicConnectionManagerActor.AcquireAsync(actor, options, TestContext.Current.CancellationToken)); - } - - [Fact(Timeout = 5000)] - public async Task Release_with_CanReuse_true_should_not_dispose() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - actor.Tell(new QuicConnectionManagerActor.Release(lease, CanReuse: true)); - - Assert.True(lease.IsAlive()); - - await lease.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Release_with_CanReuse_false_should_dispose() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - actor.Tell(new QuicConnectionManagerActor.Release(lease, CanReuse: false)); - - AwaitCondition(() => !lease.IsAlive(), TimeSpan.FromSeconds(2), - TestContext.Current.CancellationToken); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task Multiple_acquires_should_create_multiple_connections() - { - var factory = new MockFactory(); - var actor = CreateActor(factory); - var options = CreateOptions() with - { - MaxBidirectionalStreams = 1, - MaxConnectionsPerHost = 2 - }; - - var lease1 = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - var lease2 = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.NotSame(lease1, lease2); - Assert.Equal(2, factory.EstablishCount); - - await lease1.DisposeAsync(); - await lease2.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_respect_cancellation() - { - var actor = CreateActor(); - var options = CreateOptions(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAnyAsync(() => - QuicConnectionManagerActor.AcquireAsync(actor, options, cts.Token)); - } - - [Fact(Timeout = 5000)] - public async Task OnEvict_should_remove_idle_dead_leases() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease1 = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await lease1.DisposeAsync(); - - actor.Tell(new QuicConnectionManagerActor.Release(lease1, CanReuse: true)); - - actor.Tell(QuicConnectionManagerActor.Evict.Instance); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - Assert.False(lease1.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task OnEvict_should_not_remove_active_leases() - { - var actor = CreateActor(); - var options = CreateOptions() with { ConnectionLifetime = TimeSpan.FromMilliseconds(50) }; - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - actor.Tell(QuicConnectionManagerActor.Evict.Instance); - - Assert.True(lease.IsAlive()); - - await lease.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task OnEstablished_should_mark_lease_busy() - { - var factory = new MockFactory(); - var actor = CreateActor(factory); - var options = CreateOptions(); - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - Assert.Equal(1, lease.ActiveStreams); - - await lease.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task OnRelease_should_not_dispose_when_can_reuse_and_alive() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - actor.Tell(new QuicConnectionManagerActor.Release(lease, CanReuse: true)); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - Assert.True(lease.IsAlive()); - - await lease.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task OnRelease_should_dispose_when_not_alive() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await lease.DisposeAsync(); - - actor.Tell(new QuicConnectionManagerActor.Release(lease, CanReuse: true)); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task Multiple_hosts_should_maintain_separate_pools() - { - var factory = new MockFactory(); - var actor = CreateActor(factory); - var options1 = CreateOptions() with { Host = "host1.example.com" }; - var options2 = CreateOptions() with { Host = "host2.example.com" }; - - var lease1 = await QuicConnectionManagerActor.AcquireAsync(actor, options1, - TestContext.Current.CancellationToken); - var lease2 = await QuicConnectionManagerActor.AcquireAsync(actor, options2, - TestContext.Current.CancellationToken); - - Assert.NotSame(lease1, lease2); - Assert.Equal(2, factory.EstablishCount); - - await lease1.DisposeAsync(); - await lease2.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_queue_when_max_connections_reached() - { - var slowFactory = new SlowQuicConnectionFactory(TimeSpan.FromSeconds(1)); - var actor = CreateActor(slowFactory); - var options = CreateOptions() with { MaxConnectionsPerHost = 1 }; - - var acquire1Task = QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - await Assert.ThrowsAnyAsync(async () => - { - await QuicConnectionManagerActor.AcquireAsync(actor, options, cts.Token); - }); - - var lease1 = await acquire1Task; - await lease1.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_reuse_idle_lease_when_available() - { - var factory = new MockFactory(); - var actor = CreateActor(factory); - var options = CreateOptions(); - - var lease1 = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new QuicConnectionManagerActor.Release(lease1, CanReuse: true)); - - var lease2 = await QuicConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.Same(lease1, lease2); - Assert.Equal(1, factory.EstablishCount); - - await lease2.DisposeAsync(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionMigrationSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionMigrationSpec.cs deleted file mode 100644 index f105c4849..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionMigrationSpec.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Net; -using Akka.Actor; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -public sealed class QuicConnectionMigrationSpec -{ - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9000-9")] - public void QuicOptions_should_default_AllowConnectionMigration_to_true() - { - var options = new QuicTransportOptions { Host = "example.com", Port = 443 }; - Assert.True(options.AllowConnectionMigration); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9000-9")] - public void QuicOptions_should_accept_AllowConnectionMigration_false() - { - var options = new QuicTransportOptions { Host = "example.com", Port = 443, AllowConnectionMigration = false }; - Assert.False(options.AllowConnectionMigration); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9000-9")] - public void Dispatch_MigrationDetected_should_push_ConnectionMigrationDetected() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var oldEp = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 12345); - var newEp = new IPEndPoint(IPAddress.Parse("10.0.0.2"), 12345); - - sm.Dispatch(new MigrationDetected(oldEp, newEp)); - - var migrationEvent = Assert.Single(ops.PushedInbound); - var detected = Assert.IsType(migrationEvent); - Assert.Equal(oldEp, detected.OldEndPoint); - Assert.Equal(newEp, detected.NewEndPoint); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9000-9")] - public void CheckForConnectionMigration_should_detect_remote_endpoint_change_on_timer() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var initialEp = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 12345); - var changedEp = new IPEndPoint(IPAddress.Parse("10.0.0.2"), 54321); - var currentRemoteEp = initialEp; - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: async ct => { await Task.Delay(Timeout.Infinite, ct); return null; }, - getLocalEndPoint: () => new IPEndPoint(IPAddress.Loopback, 9999), - getRemoteEndPoint: () => currentRemoteEp, - dispose: () => ValueTask.CompletedTask); - - var lease = new QuicConnectionLease(handle, 100); - - sm.HandlePush(new ConnectTransport(new QuicTransportOptions { Host = "example.com", Port = 443 })); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - ops.PushedInbound.Clear(); - - currentRemoteEp = changedEp; - sm.OnTimer("migration-check"); - - Assert.Contains(ops.PushedInbound, i => i is ConnectionMigrationDetected); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9000-9")] - public void CheckForConnectionMigration_should_not_detect_when_remote_endpoint_unchanged() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var stableEp = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 12345); - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: async ct => { await Task.Delay(Timeout.Infinite, ct); return null; }, - getLocalEndPoint: () => new IPEndPoint(IPAddress.Loopback, 9999), - getRemoteEndPoint: () => stableEp, - dispose: () => ValueTask.CompletedTask); - - var lease = new QuicConnectionLease(handle, 100); - - sm.HandlePush(new ConnectTransport(new QuicTransportOptions { Host = "example.com", Port = 443 })); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - ops.PushedInbound.Clear(); - - sm.OnTimer("migration-check"); - - Assert.DoesNotContain(ops.PushedInbound, i => i is ConnectionMigrationDetected); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9000-9")] - public void InboundData_should_not_trigger_migration_check() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var changedEp = new IPEndPoint(IPAddress.Parse("10.0.0.2"), 54321); - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: async ct => { await Task.Delay(Timeout.Infinite, ct); return null; }, - getLocalEndPoint: () => new IPEndPoint(IPAddress.Loopback, 9999), - getRemoteEndPoint: () => changedEp, - dispose: () => ValueTask.CompletedTask); - - var lease = new QuicConnectionLease(handle, 100); - - sm.HandlePush(new ConnectTransport(new QuicTransportOptions { Host = "example.com", Port = 443 })); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - ops.PushedInbound.Clear(); - - var buf = TransportBuffer.Rent(4); - buf.Length = 4; - sm.Dispatch(new InboundData(buf, 0, 2)); - - Assert.DoesNotContain(ops.PushedInbound, i => i is ConnectionMigrationDetected); - - var data = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(data); - data.Buffer.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9000-9")] - public void Timer_should_reschedule_after_migration_check() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var stableEp = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 12345); - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: async ct => { await Task.Delay(Timeout.Infinite, ct); return null; }, - getLocalEndPoint: () => new IPEndPoint(IPAddress.Loopback, 9999), - getRemoteEndPoint: () => stableEp, - dispose: () => ValueTask.CompletedTask); - - var lease = new QuicConnectionLease(handle, 100); - - sm.HandlePush(new ConnectTransport(new QuicTransportOptions { Host = "example.com", Port = 443 })); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - ops.Timers.Clear(); - sm.OnTimer("migration-check"); - - Assert.True(ops.Timers.ContainsKey("migration-check")); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionStageSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionStageSpec.cs deleted file mode 100644 index 0abb3a35d..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicConnectionStageSpec.cs +++ /dev/null @@ -1,158 +0,0 @@ -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.TestKit.Xunit; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -public sealed class QuicConnectionStageSpec : TestKit -{ - private readonly IMaterializer _materializer; - - public QuicConnectionStageSpec() - { - _materializer = Sys.Materializer(); - } - - [Fact(Timeout = 5000)] - public void Stage_should_materialize_without_error() - { - var stage = new QuicConnectionStage(TestActor); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, sinkQueue) = Source - .Queue>(1, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - Assert.NotNull(sourceQueue); - Assert.NotNull(sinkQueue); - } - - [Fact(Timeout = 5000)] - public void Stage_should_have_correct_shape() - { - var stage = new QuicConnectionStage(TestActor); - - Assert.NotNull(stage.Shape); - Assert.Equal("QuicConnection.In", stage.Shape.Inlet.Name); - Assert.Equal("QuicConnection.Out", stage.Shape.Outlet.Name); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_pass_ConnectTransport_to_state_machine() - { - var options = new QuicTransportOptions - { - Host = "localhost", - Port = 443 - }; - - var stage = new QuicConnectionStage(TestActor); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, _) = Source - .Queue>(1, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - // Push ConnectTransport - await sourceQueue.OfferAsync([new ConnectTransport(options)]); - - // Expect Acquire message on TestActor from state machine - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - Assert.Equal("localhost", msg.Options.Host); - Assert.Equal(443, msg.Options.Port); - } - - [Fact(Timeout = 5000)] - public void Stage_shape_inlet_outlet_are_correctly_named() - { - var stage = new QuicConnectionStage(TestActor); - - Assert.NotNull(stage.Shape.Inlet); - Assert.NotNull(stage.Shape.Outlet); - Assert.Equal("QuicConnection.In", stage.Shape.Inlet.Name); - Assert.Equal("QuicConnection.Out", stage.Shape.Outlet.Name); - } - - [Fact(Timeout = 10000)] - public async Task Stage_should_queue_inbound_when_outlet_not_pulled() - { - var stage = new QuicConnectionStage(TestActor); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, sinkQueue) = Source - .Queue>(2, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - await sourceQueue.OfferAsync([new ConnectTransport(new QuicTransportOptions - { - Host = "localhost", - Port = 443 - })]); - - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - - // Verify that multiple inbound items can be queued when outlet is not pulled - // by simulating inbound data dispatch - Assert.NotNull(sinkQueue); - } - - [Fact(Timeout = 10000)] - public async Task Stage_should_handle_downstream_finish_signal() - { - var stage = new QuicConnectionStage(TestActor); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, _) = Source - .Queue>(1, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - // Test that the stage properly initializes and can handle lifecycle - // The OnDownstreamFinish handler is called when downstream cancels - await sourceQueue.OfferAsync([new ConnectTransport(new QuicTransportOptions - { - Host = "localhost", - Port = 443 - })]); - - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_pull_inlet_after_inbound_push() - { - var stage = new QuicConnectionStage(TestActor); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, _) = Source - .Queue>(1, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - await sourceQueue.OfferAsync([new ConnectTransport(new QuicTransportOptions - { - Host = "localhost", - Port = 443 - })]); - - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicTransportFactorySpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicTransportFactorySpec.cs deleted file mode 100644 index 45c2dc625..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicTransportFactorySpec.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Akka.Actor; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -public sealed class QuicTransportFactorySpec -{ - [Fact(Timeout = 5000)] - public void Create_should_return_non_null_flow() - { - var factory = new QuicTransportFactory(ActorRefs.Nobody); - - var flow = factory.Create(); - - Assert.NotNull(flow); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicTransportStateMachineSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Client/QuicTransportStateMachineSpec.cs deleted file mode 100644 index 6ed40de03..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Client/QuicTransportStateMachineSpec.cs +++ /dev/null @@ -1,816 +0,0 @@ -using Akka.Actor; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Tests.Transport.Quic.Client; - -public sealed class QuicTransportStateMachineSpec -{ - private static QuicConnectionHandle CreateMockHandle() - { - return new QuicConnectionHandle( - openStream: async (_, ct) => - { - await Task.Delay(0, ct).ConfigureAwait(false); - return (new MemoryStream(), 1L); - }, - acceptInboundStream: async ct => - { - await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); - return null; - }, - getLocalEndPoint: () => new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 12345), - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - } - - private static (StubOps ops, QuicTransportStateMachine sm) - CreateConnectedStateMachine() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - - sm.HandlePush(new ConnectTransport(options)); - - var handle = CreateMockHandle(); - var lease = new QuicConnectionLease(handle, 100); - - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - return (ops, sm); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_should_schedule_connect_timeout() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - - sm.HandlePush(new ConnectTransport(options)); - - Assert.Contains("connect-timeout", ops.Timers.Keys); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_OpenStream_should_reject_when_not_connected() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandlePush(new OpenStream(1, StreamDirection.Bidirectional)); - - Assert.True(ops.PullCount > 0); - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_should_complete_when_no_connection() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandleUpstreamFinish(); - - Assert.True(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void HandlePush_MultiplexedData_should_signal_pull_when_no_stream() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var buffer = TransportBuffer.Rent(16); - buffer.Length = 4; - sm.HandlePush(new MultiplexedData(buffer, 1)); - - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_CompleteWrites_should_signal_pull_when_no_stream() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandlePush(new CompleteWrites(99)); - - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ResetStream_should_signal_pull_when_no_stream() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandlePush(new ResetStream(99)); - - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundData_should_dispose_buffer_when_gen_mismatch() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var buffer = TransportBuffer.Rent(16); - buffer.Length = 4; - - sm.Dispatch(new InboundData(buffer, 1, 99)); - - // Buffer should be disposed, so accessing it should not be safe - // We verify this indirectly by checking no inbound was pushed - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteDone_should_signal_pull() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.Dispatch(new OutboundWriteDone(1)); - - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_DisconnectTransport_should_signal_pull() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandlePush(new DisconnectTransport(DisconnectReason.Graceful)); - - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_should_set_auto_reconnect_from_options() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - var options = new QuicTransportOptions - { - Host = "localhost", - Port = 443, - AutoReconnect = true - }; - - sm.HandlePush(new ConnectTransport(options)); - - Assert.Contains("connect-timeout", ops.Timers.Keys); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_MultiplexedData_should_dispose_buffer_when_stream_not_found() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var buffer = TransportBuffer.Rent(16); - buffer.Length = 4; - - sm.HandlePush(new MultiplexedData(buffer, 999)); - - // Buffer is disposed, verify no inbound was pushed - Assert.Empty(ops.PushedInbound); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandleDownstreamFinish_should_not_complete_when_upstream_not_finished() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandleDownstreamFinish(); - - // HandleDownstreamFinish should NOT call OnCompleteStage, it just cleans up - Assert.False(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_should_complete_stage() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandleUpstreamFinish(); - - Assert.True(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void OnTimer_with_connect_timeout_key_should_push_TransportDisconnected() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - // Set up pending connect - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - sm.HandlePush(new ConnectTransport(options)); - - // Now trigger the timeout - sm.OnTimer("connect-timeout"); - - Assert.NotEmpty(ops.PushedInbound); - var disconnected = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(disconnected); - Assert.Equal(DisconnectReason.Timeout, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public void OnTimer_with_unknown_key_should_do_nothing() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.OnTimer("unknown-timer-key"); - - Assert.Empty(ops.PushedInbound); - Assert.Equal(0, ops.PullCount); - } - - [Fact(Timeout = 5000)] - public void OnTimer_without_pending_connect_should_do_nothing() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.OnTimer("connect-timeout"); - - Assert.Empty(ops.PushedInbound); - Assert.Equal(0, ops.PullCount); - } - - [Fact(Timeout = 5000)] - public void PostStop_should_cancel_connect_timer() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.PostStop(); - - Assert.Contains("connect-timeout", ops.CancelledTimers); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ResetStream_should_emit_StreamClosed_when_stream_exists() - { - // This is a harder test without real connection state, but we can verify - // that calling ResetStream on unknown stream just signals pull - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandlePush(new ResetStream(999)); - - // No pushed inbound for unknown stream - Assert.Empty(ops.PushedInbound); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_CompleteWrites_on_unknown_stream_should_just_pull() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.HandlePush(new CompleteWrites(999)); - - Assert.Empty(ops.PushedInbound); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_should_handle_connection_failure() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - sm.Dispatch(new OutboundWriteFailed(new InvalidOperationException("Write failed"), 1)); - - // Should push TransportDisconnected - var disconnected = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(disconnected); - } - - [Fact(Timeout = 5000)] - public void Dispatch_AcquisitionFailed_when_cancelled_should_be_ignored() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - sm.HandlePush(new ConnectTransport(options)); - - // Dispatch acquisition failed with OperationCanceledException - sm.Dispatch(new AcquisitionFailed(new OperationCanceledException("Cancelled"))); - - // Should not push anything (cancelled exceptions are ignored) - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_AcquisitionFailed_with_error_should_push_TransportDisconnected() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - sm.HandlePush(new ConnectTransport(options)); - - // Dispatch acquisition failed with actual error - sm.Dispatch(new AcquisitionFailed(new IOException("Connection failed"))); - - // Should cancel timer and push TransportDisconnected - Assert.Contains("connect-timeout", ops.CancelledTimers); - var disconnected = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(disconnected); - Assert.Equal(DisconnectReason.Error, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundPumpFailed_should_handle_gracefully() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - // InboundPumpFailed doesn't push TransportDisconnected directly, it just calls OnInboundComplete - // which handles stream cleanup. Since the stream doesn't exist, nothing is pushed. - sm.Dispatch(new InboundPumpFailed(new IOException("Pump failed"), 1)); - - // No inbound should be pushed for non-existent stream - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_with_pending_connection_should_complete_stage() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - sm.HandlePush(new ConnectTransport(options)); - Assert.False(ops.Completed); - - sm.HandleUpstreamFinish(); - - Assert.True(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void Multiple_TimerCancelAndSchedule_should_be_tracked() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options1 = new QuicTransportOptions { Host = "localhost", Port = 443 }; - sm.HandlePush(new ConnectTransport(options1)); - Assert.Contains("connect-timeout", ops.Timers.Keys); - Assert.Empty(ops.CancelledTimers); - - // Second connect should reuse/reset the timer - var options2 = new QuicTransportOptions { Host = "other.host", Port = 443 }; - sm.HandlePush(new ConnectTransport(options2)); - Assert.Contains("connect-timeout", ops.Timers.Keys); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundData_with_matching_gen_should_push_MultiplexedData() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var buffer = TransportBuffer.Rent(16); - buffer.Length = 4; - - // Dispatch with gen 0 (initial gen), should match and push - sm.Dispatch(new InboundData(buffer, 1, 0)); - - Assert.Single(ops.PushedInbound); - Assert.IsType(ops.PushedInbound[0]); - var pushed = (MultiplexedData)ops.PushedInbound[0]; - Assert.Equal(1, pushed.StreamId); - } - - [Fact(Timeout = 5000)] - public void Dispatch_StreamLeaseAcquired_should_attach_handle_and_push_StreamOpened() - { - var (ops, sm) = CreateConnectedStateMachine(); - - const long streamId = 123L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - - // OpenStream has been queued, now dispatch the StreamLeaseAcquired - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - // Should push StreamOpened - var streamOpened = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(streamOpened); - Assert.Equal(new StreamTarget(streamId), streamOpened.Id); - Assert.Equal(StreamDirection.Bidirectional, streamOpened.Direction); - } - - [Fact(Timeout = 5000)] - public void Dispatch_StreamLeaseAcquired_with_unknown_stream_should_dispose_handle() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, 999)); - - // Should not push anything (stream doesn't exist) - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundStreamAccepted_should_register_server_stream() - { - var (ops, sm) = CreateConnectedStateMachine(); - - var streamId = 3L; - var stream = new MemoryStream(); - sm.Dispatch(new InboundStreamAccepted(stream, streamId)); - - // Should push ServerStreamAccepted - var accepted = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(accepted); - Assert.Equal(new StreamTarget(streamId), accepted.Id); - Assert.Equal(StreamDirection.Unidirectional, accepted.Direction); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_graceful_should_push_StreamReadCompleted() - { - var (ops, sm) = CreateConnectedStateMachine(); - - var streamId = 789L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - ops.PushedInbound.Clear(); - - // Now dispatch InboundComplete with Graceful reason (gen is 2 after CreateConnectedStateMachine) - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 2, streamId)); - - // Should push StreamReadCompleted - var completed = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(completed); - Assert.Equal(new StreamTarget(streamId), completed.Id); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_error_should_push_StreamClosed() - { - var (ops, sm) = CreateConnectedStateMachine(); - - var streamId = 999L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - ops.PushedInbound.Clear(); - - // Dispatch InboundComplete with error reason (gen is 2 after CreateConnectedStateMachine) - sm.Dispatch(new InboundComplete(DisconnectReason.Error, 2, streamId)); - - // Should push StreamClosed - var closed = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(closed); - Assert.Equal(new StreamTarget(streamId), closed.Id); - Assert.Equal(DisconnectReason.Error, closed.Reason); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_with_connection_should_stop_pumps_and_complete() - { - var (ops, sm) = CreateConnectedStateMachine(); - - // Now upstream finishes - sm.HandleUpstreamFinish(); - - // Should complete stage - Assert.True(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void HandleConnectTransport_with_existing_lease_should_set_reconnecting() - { - var (ops, sm) = CreateConnectedStateMachine(); - - ops.PushedInbound.Clear(); - ops.PullCount = 0; - - // Second connect with existing lease - var options2 = new QuicTransportOptions { Host = "other.host", Port = 443 }; - sm.HandlePush(new ConnectTransport(options2)); - - // Should schedule timer and signal pull - Assert.Contains("connect-timeout", ops.Timers.Keys); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandleOpenStream_with_connected_handle_should_create_stream_state() - { - var (ops, sm) = CreateConnectedStateMachine(); - - ops.PullCount = 0; - var streamId = 555L; - - sm.HandlePush(new OpenStream(streamId, StreamDirection.Unidirectional)); - - // Should signal pull (PipeTo will be sent to self) - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandleResetStream_with_existing_stream_should_abort_and_close() - { - var (ops, sm) = CreateConnectedStateMachine(); - - var streamId = 222L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - ops.PushedInbound.Clear(); - ops.PullCount = 0; - - // Now reset the stream - sm.HandlePush(new ResetStream(streamId, 42)); - - // Should push StreamClosed - var closed = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(closed); - Assert.Equal(new StreamTarget(streamId), closed.Id); - Assert.Equal(DisconnectReason.Error, closed.Reason); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_ConnectionLeaseAcquired_should_cancel_timer_and_push_TransportConnected() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - - sm.HandlePush(new ConnectTransport(options)); - Assert.Contains("connect-timeout", ops.Timers.Keys); - - ops.PushedInbound.Clear(); - - var handle = CreateMockHandle(); - var lease = new QuicConnectionLease(handle, 100); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - // Should cancel timer - Assert.Contains("connect-timeout", ops.CancelledTimers); - - // Should push TransportConnected - var connected = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(connected); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_graceful_with_state_becoming_closed_should_remove_and_dispose() - { - var (ops, sm) = CreateConnectedStateMachine(); - - var streamId = 333L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - // First, complete writes to move to HalfClosedWrite phase - sm.HandlePush(new CompleteWrites(streamId)); - - ops.PushedInbound.Clear(); - - // Now InboundComplete with Graceful moves it to Closed phase (gen is 2 after CreateConnectedStateMachine) - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 2, streamId)); - - // Should push StreamReadCompleted and remove stream from dictionary - var readCompleted = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(readCompleted); - Assert.Equal(new StreamTarget(streamId), readCompleted.Id); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_with_auto_reconnect_should_push_transient_disconnect() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options = new QuicTransportOptions { Host = "localhost", Port = 443, AutoReconnect = true }; - sm.HandlePush(new ConnectTransport(options)); - - var handle = CreateMockHandle(); - var lease = new QuicConnectionLease(handle, 100); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - ops.PushedInbound.Clear(); - - var streamId = 111L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - var streamHandle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(streamHandle, streamId)); - - ops.PushedInbound.Clear(); - - // Trigger connection failure - sm.Dispatch(new OutboundWriteFailed(new IOException("Connection failed"), streamId)); - - // Should push TransportDisconnected with Transient reason (auto-reconnect is enabled) - var disconnected = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(disconnected); - Assert.Equal(DisconnectReason.Transient, disconnected.Reason); - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_without_auto_reconnect_upstream_finished_should_complete() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options = new QuicTransportOptions { Host = "localhost", Port = 443, AutoReconnect = false }; - sm.HandlePush(new ConnectTransport(options)); - - var handle = CreateMockHandle(); - var lease = new QuicConnectionLease(handle, 100); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - ops.PushedInbound.Clear(); - ops.Completed = false; - - // Mark upstream finished - sm.HandleUpstreamFinish(); - - ops.PushedInbound.Clear(); - ops.Completed = false; - - // Trigger connection failure - sm.Dispatch(new OutboundWriteFailed(new IOException("Connection failed"), 1)); - - // Should push TransportDisconnected with Error reason - var disconnected = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(disconnected); - Assert.Equal(DisconnectReason.Error, disconnected.Reason); - - // Should complete stage - Assert.True(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_without_auto_reconnect_upstream_not_finished_should_pull() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options = new QuicTransportOptions { Host = "localhost", Port = 443, AutoReconnect = false }; - sm.HandlePush(new ConnectTransport(options)); - - var handle = CreateMockHandle(); - var lease = new QuicConnectionLease(handle, 100); - sm.Dispatch(new ConnectionLeaseAcquired(lease)); - - ops.PushedInbound.Clear(); - ops.PullCount = 0; - - // Trigger connection failure (upstream not finished) - sm.Dispatch(new OutboundWriteFailed(new IOException("Connection failed"), 1)); - - // Should push TransportDisconnected - var disconnected = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(disconnected); - Assert.Equal(DisconnectReason.Error, disconnected.Reason); - - // Should signal pull - Assert.True(ops.PullCount > 0); - - // Should NOT complete stage - Assert.False(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_should_create_cts_and_send_acquire() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var options = new QuicTransportOptions { Host = "localhost", Port = 443 }; - sm.HandlePush(new ConnectTransport(options)); - - // Should schedule timer - Assert.Contains("connect-timeout", ops.Timers.Keys); - - // Should signal pull (PipeTo sends message to self) - Assert.True(ops.PullCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandleDownstreamFinish_should_call_cleanup_transport() - { - var (ops, sm) = CreateConnectedStateMachine(); - - ops.PullCount = 0; - - sm.HandleDownstreamFinish(); - - // HandleDownstreamFinish calls CleanupTransport but doesn't complete stage - Assert.False(ops.Completed); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundPumpFailed_should_remove_stream_on_error() - { - var (ops, sm) = CreateConnectedStateMachine(); - - StreamTarget streamId = 888L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - ops.PushedInbound.Clear(); - - // InboundPumpFailed should call OnInboundComplete with Error reason - sm.Dispatch(new InboundPumpFailed(new IOException("Pump failed"), streamId)); - - // Should push StreamClosed - var closed = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(closed); - Assert.Equal(streamId, closed.Id); - Assert.Equal(DisconnectReason.Error, closed.Reason); - } - - [Fact(Timeout = 5000)] - public void Dispatch_StreamLeaseAcquired_for_unidirectional_should_not_start_inbound_pump() - { - var (ops, sm) = CreateConnectedStateMachine(); - - StreamTarget streamId = 42L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Unidirectional)); - - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - var streamOpened = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(streamOpened); - Assert.Equal(streamId, streamOpened.Id); - Assert.Equal(StreamDirection.Unidirectional, streamOpened.Direction); - - // Wait briefly to ensure no InboundPumpFailed is dispatched. - // If a pump was started on a write-only MemoryStream, ReadAsync would - // return 0 immediately and trigger InboundComplete — which must NOT happen - // for client-initiated unidirectional control streams. - Thread.Sleep(50); - Assert.DoesNotContain(ops.PushedInbound, item => item is StreamClosed); - Assert.DoesNotContain(ops.PushedInbound, item => item is StreamReadCompleted); - } - - [Fact(Timeout = 5000)] - public void Dispatch_StreamLeaseAcquired_for_bidirectional_should_start_inbound_pump() - { - var (ops, sm) = CreateConnectedStateMachine(); - - const long streamId = 50L; - sm.HandlePush(new OpenStream(streamId, StreamDirection.Bidirectional)); - - var handle = new StreamHandle(new MemoryStream()); - sm.Dispatch(new StreamLeaseAcquired(handle, streamId)); - - var streamOpened = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(streamOpened); - Assert.Equal(StreamDirection.Bidirectional, streamOpened.Direction); - } - - [Fact(Timeout = 5000)] - public void Dispatch_MigrationDetected_should_push_ConnectionMigrationDetected() - { - var ops = new StubOps(); - var sm = new QuicTransportStateMachine(ops, ActorRefs.Nobody, ActorRefs.Nobody); - - var oldEndPoint = new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 1234); - var newEndPoint = new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 5678); - - sm.Dispatch(new MigrationDetected(oldEndPoint, newEndPoint)); - - var migrated = ops.PushedInbound.OfType().FirstOrDefault(); - Assert.NotNull(migrated); - Assert.Equal(oldEndPoint, migrated.OldEndPoint); - Assert.Equal(newEndPoint, migrated.NewEndPoint); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicListenerFactorySpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicListenerFactorySpec.cs deleted file mode 100644 index 7e063e2f1..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicListenerFactorySpec.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Net.Security; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic.Listener; - -namespace Servus.Akka.Tests.Transport.Quic.Listener; - -public sealed class QuicListenerFactorySpec -{ - [Fact(Timeout = 5000)] - public void Bind_should_return_non_null_source() - { - var factory = new QuicListenerFactory(); - - var source = factory.Bind(new QuicListenerOptions - { - Host = "127.0.0.1", - Port = 0, - ServerCertificate = null!, - ApplicationProtocols = [SslApplicationProtocol.Http3] - }); - - Assert.NotNull(source); - } - - [Fact(Timeout = 5000)] - public void Bind_should_throw_for_wrong_options_type() - { - var factory = new QuicListenerFactory(); - - Assert.Throws(() => - factory.Bind(new TcpListenerOptions - { - Host = "127.0.0.1", - Port = 0 - })); - } - - [Fact(Timeout = 5000)] - public void Bind_should_return_independent_sources() - { - var factory = new QuicListenerFactory(); - var options = new QuicListenerOptions - { - Host = "127.0.0.1", - Port = 0, - ServerCertificate = null!, - ApplicationProtocols = [SslApplicationProtocol.Http3] - }; - - var source1 = factory.Bind(options); - var source2 = factory.Bind(options); - - Assert.NotSame(source1, source2); - } - - [Fact(Timeout = 5000)] - public void Bind_with_custom_options_should_not_throw() - { - var factory = new QuicListenerFactory(); - var options = new QuicListenerOptions - { - Host = "127.0.0.1", - Port = 0, - ServerCertificate = null!, - ApplicationProtocols = [SslApplicationProtocol.Http3], - MaxInboundBidirectionalStreams = 50, - MaxInboundUnidirectionalStreams = 5, - IdleTimeout = TimeSpan.FromSeconds(60), - Backlog = 64 - }; - - var source = factory.Bind(options); - - Assert.NotNull(source); - } - - [Fact(Timeout = 5000)] - public void QuicListenerFactory_should_implement_IListenerFactory() - { - var factory = new QuicListenerFactory(); - - Assert.IsAssignableFrom(factory); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicServerConnectionStageSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicServerConnectionStageSpec.cs deleted file mode 100644 index 6593850ec..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicServerConnectionStageSpec.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Net; -using Akka.Streams; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; -using Servus.Akka.Transport.Quic.Listener; - -namespace Servus.Akka.Tests.Transport.Quic.Listener; - -public sealed class QuicServerConnectionStageSpec -{ - [Fact(Timeout = 5000)] - public void QuicServerConnectionStage_should_have_flow_shape() - { - var connectionHandle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult<(Stream, long)>((Stream.Null, 1)), - acceptInboundStream: async ct => { await Task.Delay(Timeout.Infinite, ct); return null; }, - getLocalEndPoint: () => new IPEndPoint(IPAddress.Loopback, 5000), - getRemoteEndPoint: () => null, - dispose: () => default); - - var connectionInfo = new ConnectionInfo( - new IPEndPoint(IPAddress.Loopback, 5000), - new IPEndPoint(IPAddress.Loopback, 12345), - TransportProtocol.None); - - var stage = new QuicServerConnectionStage(connectionHandle, connectionInfo); - - Assert.NotNull(stage.Shape); - Assert.IsType>(stage.Shape); - } - - [Fact(Timeout = 5000)] - public void QuicServerConnectionStage_shape_should_have_correct_port_names() - { - var connectionHandle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult<(Stream, long)>((Stream.Null, 1)), - acceptInboundStream: async ct => { await Task.Delay(Timeout.Infinite, ct); return null; }, - getLocalEndPoint: () => new IPEndPoint(IPAddress.Loopback, 5000), - getRemoteEndPoint: () => null, - dispose: () => default); - - var connectionInfo = new ConnectionInfo( - new IPEndPoint(IPAddress.Loopback, 5000), - new IPEndPoint(IPAddress.Loopback, 12345), - TransportProtocol.None); - - var stage = new QuicServerConnectionStage(connectionHandle, connectionInfo); - var shape = stage.Shape; - - Assert.Contains("QuicServerConnection", shape.Inlet.ToString()); - Assert.Contains("QuicServerConnection", shape.Outlet.ToString()); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicServerStateMachineSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicServerStateMachineSpec.cs deleted file mode 100644 index 10e51de1d..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/Listener/QuicServerStateMachineSpec.cs +++ /dev/null @@ -1,360 +0,0 @@ -using System.Net; -using Akka.Actor; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; -using Servus.Akka.Transport.Quic.Listener; - -namespace Servus.Akka.Tests.Transport.Quic.Listener; - -public sealed class QuicServerStateMachineSpec -{ - private static readonly ConnectionInfo TestConnectionInfo = new( - new IPEndPoint(IPAddress.Loopback, 5000), - new IPEndPoint(IPAddress.Loopback, 12345), - TransportProtocol.Tcp); - - private static QuicConnectionHandle CreateTestHandle() - { - return new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult<(Stream, long)>((Stream.Null, 1)), - acceptInboundStream: async ct => - { - await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); - return null; - }, - getLocalEndPoint: () => new IPEndPoint(IPAddress.Loopback, 5000), - getRemoteEndPoint: () => null, - dispose: () => default); - } - - private static (QuicServerStateMachine Sm, MockTransportOperations Ops) CreateStateMachine( - QuicConnectionHandle? handle = null) - { - var ops = new MockTransportOperations(); - var sm = new QuicServerStateMachine( - ops, - ActorRefs.Nobody, - handle ?? CreateTestHandle(), - TestConnectionInfo); - return (sm, ops); - } - - private static TransportBuffer CreateTestBuffer(params byte[] data) - { - var buf = TransportBuffer.Rent(data.Length); - data.CopyTo(buf.FullMemory.Span); - buf.Length = data.Length; - return buf; - } - - [Fact(Timeout = 5000)] - public void Start_should_emit_TransportConnected() - { - var (sm, ops) = CreateStateMachine(); - - sm.Start(); - - Assert.Single(ops.PushedInbound); - var connected = Assert.IsType(ops.PushedInbound[0]); - Assert.Equal(TestConnectionInfo, connected.Info); - } - - [Fact(Timeout = 5000)] - public void HandlePush_OpenStream_should_signal_pull_outbound() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - ops.PullOutboundCount = 0; - - sm.HandlePush(new OpenStream(1, StreamDirection.Bidirectional)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_MultiplexedData_with_unknown_stream_should_dispose_buffer() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PullOutboundCount = 0; - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new MultiplexedData(buffer, 999)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_DisconnectTransport_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - - sm.HandlePush(new DisconnectTransport(DisconnectReason.Graceful)); - - Assert.True(ops.CompleteStageCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - - sm.HandleUpstreamFinish(); - - Assert.True(ops.CompleteStageCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundData_should_push_multiplexed_data() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.Dispatch(new InboundData(buffer, 42, 1)); - - Assert.Single(ops.PushedInbound); - var multiplexed = Assert.IsType(ops.PushedInbound[0]); - Assert.Equal(new StreamTarget(42L), multiplexed.StreamId); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundData_with_stale_gen_should_dispose_buffer() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.Dispatch(new InboundData(buffer, 42, 999)); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_should_push_error_disconnected() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.Dispatch(new OutboundWriteFailed(new IOException("test"), 0)); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - } - - [Fact(Timeout = 5000)] - public void PostStop_should_not_throw() - { - var (sm, _) = CreateStateMachine(); - sm.Start(); - - sm.PostStop(); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ResetStream_with_no_active_stream_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PullOutboundCount = 0; - - sm.HandlePush(new ResetStream(999)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundStreamAccepted_should_push_ServerStreamAccepted() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - var stream = new MemoryStream(); - sm.Dispatch(new InboundStreamAccepted(stream, 42)); - - Assert.Contains(ops.PushedInbound, item => item is ServerStreamAccepted { Id.Value: 42 }); - } - - [Fact(Timeout = 5000)] - public void HandlePush_CompleteWrites_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PullOutboundCount = 0; - - sm.HandlePush(new CompleteWrites(1)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_MultiplexedData_with_known_stream_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PullOutboundCount = 0; - - sm.HandlePush(new OpenStream(1, StreamDirection.Bidirectional)); - ops.PullOutboundCount = 0; - - var stream = Stream.Null; - sm.Dispatch(new StreamLeaseAcquired(new StreamHandle(stream), 1)); - ops.PullOutboundCount = 0; - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new MultiplexedData(buffer, 1)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_graceful_should_push_StreamReadCompleted() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.HandlePush(new OpenStream(1, StreamDirection.Bidirectional)); - - var stream = Stream.Null; - sm.Dispatch(new StreamLeaseAcquired(new StreamHandle(stream), 1)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 1, 1)); - - Assert.Contains(ops.PushedInbound, item => item is StreamReadCompleted { Id.Value: 1 }); - } - - [Fact(Timeout = 5000)] - public void HandleConnectionFailure_via_OutboundWriteFailed_with_upstream_finished_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - sm.HandleUpstreamFinish(); - ops.CompleteStageCount = 0; - ops.PushedInbound.Clear(); - - sm.Dispatch(new OutboundWriteFailed(new IOException("test"), 0)); - - Assert.True(ops.CompleteStageCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_CompleteWrites_with_no_stream_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PullOutboundCount = 0; - - sm.HandlePush(new CompleteWrites(999)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ResetStream_with_active_stream_should_push_StreamClosed() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.HandlePush(new OpenStream(1, StreamDirection.Bidirectional)); - sm.Dispatch(new StreamLeaseAcquired(new StreamHandle(Stream.Null), 1)); - ops.PushedInbound.Clear(); - - sm.HandlePush(new ResetStream(1)); - - Assert.Contains(ops.PushedInbound, item => item is StreamClosed { Id.Value: 1 }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_error_should_push_StreamClosed() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - - sm.HandlePush(new OpenStream(1, StreamDirection.Bidirectional)); - sm.Dispatch(new StreamLeaseAcquired(new StreamHandle(Stream.Null), 1)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Error, 1, 1)); - - Assert.Contains(ops.PushedInbound, - item => item is StreamClosed { Id.Value: 1, Reason: DisconnectReason.Error }); - } - - [Fact(Timeout = 5000)] - public void OnStreamLeaseAcquired_with_unknown_stream_should_dispose_handle() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.Dispatch(new StreamLeaseAcquired(new StreamHandle(Stream.Null), 999)); - - Assert.DoesNotContain(ops.PushedInbound, item => item is StreamOpened { Id.Value: 999 }); - } - - [Fact(Timeout = 5000)] - public void HandlePush_OpenStream_when_handle_is_null_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new OpenStream(1, StreamDirection.Bidirectional)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void PostStop_before_start_should_not_throw() - { - var (sm, _) = CreateStateMachine(); - - sm.PostStop(); - } - - [Fact(Timeout = 5000)] - public void HandleDownstreamFinish_should_cleanup() - { - var (sm, _) = CreateStateMachine(); - sm.Start(); - - sm.HandleDownstreamFinish(); - - sm.PostStop(); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteDone_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PullOutboundCount = 0; - - sm.Dispatch(new OutboundWriteDone()); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_MultiplexedData_after_disconnect_should_dispose_buffer() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - - sm.HandlePush(new DisconnectTransport(DisconnectReason.Graceful)); - ops.PullOutboundCount = 0; - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new MultiplexedData(buffer, 1)); - - Assert.True(ops.PullOutboundCount > 0); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/QuicConnectionHandleSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/QuicConnectionHandleSpec.cs deleted file mode 100644 index e255def0c..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/QuicConnectionHandleSpec.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System.Net; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; - -namespace Servus.Akka.Tests.Transport.Quic; - -public sealed class QuicConnectionHandleSpec -{ - [Fact(Timeout = 5000)] - public async Task OpenStreamAsync_should_delegate_to_factory() - { - var openStreamCalled = false; - const long expectedStreamId = 42L; - Stream expectedStream = new MemoryStream([0x01, 0x02, 0x03]); - - var handle = new QuicConnectionHandle( - openStream: (dir, _) => - { - openStreamCalled = true; - Assert.Equal(StreamDirection.Bidirectional, dir); - return Task.FromResult((expectedStream, expectedStreamId)); - }, - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - var result = await handle.OpenStreamAsync(StreamDirection.Bidirectional, TestContext.Current.CancellationToken); - - Assert.True(openStreamCalled); - Assert.Equal(expectedStreamId, result.StreamId); - Assert.Same(expectedStream, result.Stream); - } - - [Fact(Timeout = 5000)] - public async Task OpenStreamAsync_should_pass_direction_correctly() - { - var capturedDirections = new List(); - var handle = new QuicConnectionHandle( - openStream: (dir, _) => - { - capturedDirections.Add(dir); - return Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)); - }, - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - await handle.OpenStreamAsync(StreamDirection.Bidirectional, TestContext.Current.CancellationToken); - await handle.OpenStreamAsync(StreamDirection.Unidirectional, TestContext.Current.CancellationToken); - - Assert.Equal(2, capturedDirections.Count); - Assert.Equal(StreamDirection.Bidirectional, capturedDirections[0]); - Assert.Equal(StreamDirection.Unidirectional, capturedDirections[1]); - } - - [Fact(Timeout = 5000)] - public async Task OpenStreamAsync_should_pass_cancellation_token() - { - var capturedTokens = new List(); - var cts = new CancellationTokenSource(); - - var handle = new QuicConnectionHandle( - openStream: (_, ct) => - { - capturedTokens.Add(ct); - return Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)); - }, - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - await handle.OpenStreamAsync(StreamDirection.Bidirectional, cts.Token); - - Assert.Single(capturedTokens); - Assert.Equal(cts.Token, capturedTokens[0]); - } - - [Fact(Timeout = 5000)] - public async Task AcceptInboundStreamAsync_should_return_null_when_no_streams() - { - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - var result = await handle.AcceptInboundStreamAsync(TestContext.Current.CancellationToken); - - Assert.Null(result); - } - - [Fact(Timeout = 5000)] - public async Task AcceptInboundStreamAsync_should_return_stream_when_available() - { - var expectedStreamId = 123L; - var expectedStream = new MemoryStream([0xAA, 0xBB, 0xCC]); - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>( - (expectedStream, expectedStreamId)), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - var result = await handle.AcceptInboundStreamAsync(TestContext.Current.CancellationToken); - - Assert.NotNull(result); - Assert.Equal(expectedStreamId, result.Value.StreamId); - Assert.Same(expectedStream, result.Value.Stream); - } - - [Fact(Timeout = 5000)] - public async Task AcceptInboundStreamAsync_should_pass_cancellation_token() - { - var capturedTokens = new List(); - var cts = new CancellationTokenSource(); - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: ct => - { - capturedTokens.Add(ct); - return Task.FromResult<(Stream, long)?>(null); - }, - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - await handle.AcceptInboundStreamAsync(cts.Token); - - Assert.Single(capturedTokens); - Assert.Equal(cts.Token, capturedTokens[0]); - } - - [Fact(Timeout = 5000)] - public void LocalEndPoint_should_delegate_to_factory() - { - var endPoint = new IPEndPoint(IPAddress.Loopback, 8080); - var getLocalEndPointCalled = false; - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => - { - getLocalEndPointCalled = true; - return endPoint; - }, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - var result = handle.LocalEndPoint(); - - Assert.True(getLocalEndPointCalled); - Assert.Same(endPoint, result); - } - - [Fact(Timeout = 5000)] - public void LocalEndPoint_should_return_null_when_unavailable() - { - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - var result = handle.LocalEndPoint(); - - Assert.Null(result); - } - - [Fact(Timeout = 5000)] - public async Task DisposeAsync_should_delegate_to_factory() - { - var disposeCalled = false; - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => - { - disposeCalled = true; - return ValueTask.CompletedTask; - }); - - Assert.False(disposeCalled); - - await handle.DisposeAsync(); - - Assert.True(disposeCalled); - } - - [Fact(Timeout = 5000)] - public async Task DisposeAsync_should_complete_successfully() - { - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - // Should not throw - await handle.DisposeAsync(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/QuicMultiStreamSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/QuicMultiStreamSpec.cs deleted file mode 100644 index cefb73c46..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/QuicMultiStreamSpec.cs +++ /dev/null @@ -1,220 +0,0 @@ -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic.Client; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Quic; - -public sealed class QuicMultiStreamSpec -{ - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public void TcpClientProvider_can_be_instantiated() - { - var provider = new TcpClientProvider(new TcpTransportOptions { Host = "localhost", Port = 80 }); - Assert.NotNull(provider); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public void TlsClientProvider_can_be_instantiated() - { - var provider = new TlsClientProvider(new TlsTransportOptions { Host = "localhost", Port = 443 }); - Assert.NotNull(provider); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public void QuicClientProvider_can_be_instantiated() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "example.com", Port = 443 }); - Assert.NotNull(provider); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public void DefaultInterface_SupportsMultipleStreams_ReturnsFalse() - { - IClientProvider provider = new MinimalClientProvider(); - Assert.False(provider.SupportsMultipleStreams); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task QuicClientProvider_ThrowsOnEmptyHost() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "", Port = 443 }); - - var ex = await Assert.ThrowsAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - Assert.Contains("SNI", ex.Message); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task QuicClientProvider_ThrowsOnNullHost() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = null!, Port = 443 }); - - var ex = await Assert.ThrowsAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - Assert.Contains("SNI", ex.Message); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public void QuicClientProvider_can_be_instantiated_with_host_and_port() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "example.com", Port = 443 }); - Assert.NotNull(provider); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task ReentrantStreamProvider_OpensMultipleStreams() - { - var provider = new FakeReentrantProvider(streamCount: 5); - - var stream1 = await provider.GetStreamAsync(TestContext.Current.CancellationToken); - var stream2 = await provider.GetStreamAsync(TestContext.Current.CancellationToken); - var stream3 = await provider.GetStreamAsync(TestContext.Current.CancellationToken); - - Assert.NotSame(stream1, stream2); - Assert.NotSame(stream2, stream3); - Assert.Equal(1, provider.ConnectionCount); - Assert.Equal(3, provider.StreamCount); - Assert.True(provider.SupportsMultipleStreams); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task ConcurrentGetStreamAsync_CreatesOneConnection() - { - var provider = new FakeReentrantProvider(streamCount: 10, connectDelay: TimeSpan.FromMilliseconds(50)); - - // Launch 5 concurrent GetStreamAsync calls - var tasks = new Task[5]; - for (var i = 0; i < tasks.Length; i++) - { - tasks[i] = provider.GetStreamAsync(TestContext.Current.CancellationToken); - } - - var streams = await Task.WhenAll(tasks); - - Assert.Equal(1, provider.ConnectionCount); - Assert.Equal(5, provider.StreamCount); - - // All streams should be distinct - for (var i = 0; i < streams.Length; i++) - { - for (var j = i + 1; j < streams.Length; j++) - { - Assert.NotSame(streams[i], streams[j]); - } - } - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task DeadConnection_TriggersReconnect() - { - var provider = new FakeReentrantProvider(streamCount: 10); - - // First stream succeeds - var stream1 = await provider.GetStreamAsync(TestContext.Current.CancellationToken); - Assert.Equal(1, provider.ConnectionCount); - - // Simulate connection death - provider.KillConnection(); - - // Next call should reconnect - var stream2 = await provider.GetStreamAsync(TestContext.Current.CancellationToken); - Assert.Equal(2, provider.ConnectionCount); - Assert.NotSame(stream1, stream2); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task StreamOpenFailure_WrapsAsReconnectableError() - { - var provider = new FakeReentrantProvider(streamCount: 10, failStreamOpen: true); - - var ex = await Assert.ThrowsAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - Assert.Contains("no longer usable", ex.Message); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task QuicClientProvider_DisposeAsync_should_be_idempotent() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "example.com", Port = 443 }); - - // Should not throw on first dispose - await provider.DisposeAsync(); - - // Should not throw on second dispose - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task QuicClientProvider_DisposeAsync_without_connection_should_complete() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "example.com", Port = 443 }); - - // Dispose without ever calling GetStreamAsync (no connection established) - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public void QuicClientProvider_LocalEndPoint_should_be_null_before_connect() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "example.com", Port = 443 }); - - Assert.Null(provider.LocalEndPoint); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task QuicClientProvider_GetStreamAsync_with_empty_host_should_throw_InvalidOperationException() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "", Port = 443 }); - - var ex = await Assert.ThrowsAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - Assert.Contains("SNI", ex.Message); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task QuicClientProvider_ConcurrentDispose_should_be_safe() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "example.com", Port = 443 }); - - // Launch concurrent dispose calls - var tasks = new Task[5]; - for (var i = 0; i < tasks.Length; i++) - { - tasks[i] = provider.DisposeAsync().AsTask(); - } - - // Should complete without throwing - await Task.WhenAll(tasks); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-3")] - public async Task QuicClientProvider_GetStreamAsync_should_respect_cancellation() - { - var provider = new QuicClientProvider(new QuicTransportOptions { Host = "example.com", Port = 443 }); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Should throw TaskCanceledException due to pre-cancelled token - await Assert.ThrowsAsync(() => - provider.GetStreamAsync(cts.Token)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/QuicPumpManagerSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/QuicPumpManagerSpec.cs deleted file mode 100644 index a0cd01af0..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/QuicPumpManagerSpec.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Akka.TestKit.Xunit; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; - -namespace Servus.Akka.Tests.Transport.Quic; - -public sealed class QuicPumpManagerSpec : TestKit -{ - [Fact(Timeout = 5000)] - public void StartInboundPump_should_emit_InboundData_for_readable_stream() - { - var ms = new MemoryStream([0x01, 0x02, 0x03]); - var handle = new StreamHandle(ms); - var manager = new QuicPumpManager(TestActor); - - manager.StartInboundPump(handle, streamId: 42, gen: 1); - - var msg = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(42, msg.StreamId); - Assert.Equal(1, msg.Gen); - Assert.True(msg.Buffer.Length > 0); - msg.Buffer.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartInboundPump_should_emit_InboundComplete_when_stream_ends() - { - var ms = new MemoryStream([]); - var handle = new StreamHandle(ms); - var manager = new QuicPumpManager(TestActor); - - manager.StartInboundPump(handle, streamId: 43, gen: 2); - - var msg = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(43, msg.StreamId); - Assert.Equal(2, msg.Gen); - Assert.Equal(DisconnectReason.Graceful, msg.Reason); - } - - [Fact(Timeout = 5000)] - public void StopAll_should_cancel_pumps() - { - var ms = new SlowStream(); - var handle = new StreamHandle(ms); - var manager = new QuicPumpManager(TestActor); - - manager.StartInboundPump(handle, streamId: 44, gen: 3); - - // Give pump a moment to start - Thread.Sleep(50); - - manager.StopAll(); - - // Verify pump is cancelled — expect no messages after a brief timeout - ExpectNoMsg(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); - } - - [Fact(Timeout = 5000)] - public void StartInboundPump_should_emit_InboundPumpFailed_on_error() - { - var failStream = new FailingStream(); - var handle = new StreamHandle(failStream); - var manager = new QuicPumpManager(TestActor); - - manager.StartInboundPump(handle, streamId: 45, gen: 4); - - var msg = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(45, msg.StreamId); - Assert.IsType(msg.Error); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/QuicStreamStateSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/QuicStreamStateSpec.cs deleted file mode 100644 index 15f9bf76c..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/QuicStreamStateSpec.cs +++ /dev/null @@ -1,335 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; - -namespace Servus.Akka.Tests.Transport.Quic; - -public sealed class QuicStreamStateSpec -{ - [Fact(Timeout = 5000)] - public void New_state_should_be_Opening() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - Assert.Equal(StreamPhase.Opening, state.Phase); - Assert.False(state.HasHandle); - } - - [Fact(Timeout = 5000)] - public void Write_in_Opening_should_buffer() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - var buf = TransportBuffer.Rent(2); - buf.FullMemory.Span[0] = 0x01; - buf.FullMemory.Span[1] = 0x02; - buf.Length = 2; - - state.Write(buf); - - Assert.Equal(StreamPhase.Opening, state.Phase); - Assert.Equal(1, state.PendingWriteCount); - } - - [Fact(Timeout = 5000)] - public void CompleteWrites_in_Opening_should_defer() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.CompleteWrites(); - - Assert.Equal(StreamPhase.Opening, state.Phase); - Assert.True(state.IsCompleteWritesDeferred); - } - - [Fact(Timeout = 5000)] - public void AttachHandle_should_transition_to_Active() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - var handle = new StreamHandle(new MemoryStream()); - - state.AttachHandle(handle); - - Assert.Equal(StreamPhase.Active, state.Phase); - Assert.True(state.HasHandle); - } - - [Fact(Timeout = 5000)] - public void AttachHandle_should_flush_pending_writes() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - var buf = TransportBuffer.Rent(2); - buf.FullMemory.Span[0] = 0x01; - buf.FullMemory.Span[1] = 0x02; - buf.Length = 2; - state.Write(buf); - - var handle = new StreamHandle(new MemoryStream()); - state.AttachHandle(handle); - - Assert.Equal(0, state.PendingWriteCount); - } - - [Fact(Timeout = 5000)] - public void AttachHandle_with_deferred_CompleteWrites_should_transition_to_HalfClosedWrite() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.CompleteWrites(); - - state.AttachHandle(new StreamHandle(new MemoryStream())); - - Assert.Equal(StreamPhase.HalfClosedWrite, state.Phase); - } - - [Fact(Timeout = 5000)] - public void CompleteWrites_in_Active_should_transition_to_HalfClosedWrite() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.AttachHandle(new StreamHandle(new MemoryStream())); - - state.CompleteWrites(); - - Assert.Equal(StreamPhase.HalfClosedWrite, state.Phase); - } - - [Fact(Timeout = 5000)] - public void OnReadCompleted_in_HalfClosedWrite_should_transition_to_Closed() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.AttachHandle(new StreamHandle(new MemoryStream())); - state.CompleteWrites(); - - state.OnReadCompleted(); - - Assert.Equal(StreamPhase.Closed, state.Phase); - } - - [Fact(Timeout = 5000)] - public void OnReadCompleted_in_Active_should_transition_to_HalfClosedRead() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.AttachHandle(new StreamHandle(new MemoryStream())); - - state.OnReadCompleted(); - - Assert.Equal(StreamPhase.HalfClosedRead, state.Phase); - } - - [Fact(Timeout = 5000)] - public void CompleteWrites_in_HalfClosedRead_should_transition_to_Closed() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.AttachHandle(new StreamHandle(new MemoryStream())); - state.OnReadCompleted(); - - Assert.Equal(StreamPhase.HalfClosedRead, state.Phase); - - state.CompleteWrites(); - - Assert.Equal(StreamPhase.Closed, state.Phase); - } - - [Fact(Timeout = 5000)] - public void Abort_should_transition_to_Closed() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.AttachHandle(new StreamHandle(new MemoryStream())); - - state.Abort(0); - - Assert.Equal(StreamPhase.Closed, state.Phase); - } - - [Fact(Timeout = 5000)] - public void DisposePendingWrites_should_clear_buffered_writes() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - var buf1 = TransportBuffer.Rent(2); - buf1.FullMemory.Span[0] = 0x01; - buf1.FullMemory.Span[1] = 0x02; - buf1.Length = 2; - state.Write(buf1); - - Assert.Equal(1, state.PendingWriteCount); - - // Dispose is called indirectly through DisposeAsync - // We test by disposing the state and verifying buffers are released - _ = state.DisposeAsync(); - - // After dispose, pending writes should be cleared - Assert.Equal(0, state.PendingWriteCount); - } - - [Fact(Timeout = 5000)] - public async ValueTask DisposeAsync_should_clean_up_handle() - { - var stream = new MemoryStream(); - var handle = new StreamHandle(stream); - var state = new QuicStreamState(StreamDirection.Bidirectional); - - state.AttachHandle(handle); - Assert.True(state.HasHandle); - - await state.DisposeAsync(); - - // After dispose, handle should be cleaned up (internal _handle = null) - // We verify indirectly: another dispose should not throw - await state.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public void Abort_in_Opening_should_transition_to_Closed() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - - state.Abort(0); - - Assert.Equal(StreamPhase.Closed, state.Phase); - } - - [Fact(Timeout = 5000)] - public void Multiple_buffered_writes_should_all_be_flushed() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - - var buf1 = TransportBuffer.Rent(1); - buf1.FullMemory.Span[0] = 0x01; - buf1.Length = 1; - state.Write(buf1); - - var buf2 = TransportBuffer.Rent(1); - buf2.FullMemory.Span[0] = 0x02; - buf2.Length = 1; - state.Write(buf2); - - var buf3 = TransportBuffer.Rent(1); - buf3.FullMemory.Span[0] = 0x03; - buf3.Length = 1; - state.Write(buf3); - - Assert.Equal(3, state.PendingWriteCount); - - var stream = new MemoryStream(); - var handle = new StreamHandle(stream); - state.AttachHandle(handle); - - Assert.Equal(0, state.PendingWriteCount); - Assert.Equal(3, stream.Length); - } - - [Fact(Timeout = 5000)] - public void Write_in_Active_should_write_to_handle_directly() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - var stream = new MemoryStream(); - var handle = new StreamHandle(stream); - - state.AttachHandle(handle); - - var buf = TransportBuffer.Rent(2); - buf.FullMemory.Span[0] = 0xAA; - buf.FullMemory.Span[1] = 0xBB; - buf.Length = 2; - - state.Write(buf); - - Assert.Equal(2, stream.Length); - Assert.Equal(0xAA, stream.GetBuffer()[0]); - Assert.Equal(0xBB, stream.GetBuffer()[1]); - } - - [Fact(Timeout = 5000)] - public void Write_in_HalfClosedWrite_still_writes_to_handle() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - var stream = new MemoryStream(); - var handle = new StreamHandle(stream); - - state.AttachHandle(handle); - state.CompleteWrites(); - - Assert.Equal(StreamPhase.HalfClosedWrite, state.Phase); - - var buf = TransportBuffer.Rent(2); - buf.FullMemory.Span[0] = 0xCC; - buf.FullMemory.Span[1] = 0xDD; - buf.Length = 2; - - state.Write(buf); - - // Write still goes to handle (no phase check in Write method) - Assert.Equal(2, stream.Length); - } - - [Fact(Timeout = 5000)] - public void CompleteWrites_in_HalfClosedWrite_should_be_no_op() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.AttachHandle(new StreamHandle(new MemoryStream())); - state.CompleteWrites(); - - Assert.Equal(StreamPhase.HalfClosedWrite, state.Phase); - - // Calling again should not change phase - state.CompleteWrites(); - - Assert.Equal(StreamPhase.HalfClosedWrite, state.Phase); - } - - [Fact(Timeout = 5000)] - public void OnReadCompleted_in_HalfClosedRead_should_stay_in_HalfClosedRead() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - state.AttachHandle(new StreamHandle(new MemoryStream())); - state.OnReadCompleted(); - - Assert.Equal(StreamPhase.HalfClosedRead, state.Phase); - - // Calling again should be idempotent - state.OnReadCompleted(); - - Assert.Equal(StreamPhase.HalfClosedRead, state.Phase); - } - - [Fact(Timeout = 5000)] - public void Direction_should_return_construction_value() - { - var stateBidirectional = new QuicStreamState(StreamDirection.Bidirectional); - Assert.Equal(StreamDirection.Bidirectional, stateBidirectional.Direction); - - var stateUnidirectional = new QuicStreamState(StreamDirection.Unidirectional); - Assert.Equal(StreamDirection.Unidirectional, stateUnidirectional.Direction); - } - - [Fact(Timeout = 5000)] - public void AttachHandle_with_deferred_writes_and_deferred_CompleteWrites() - { - var state = new QuicStreamState(StreamDirection.Bidirectional); - - // Buffer writes - var buf1 = TransportBuffer.Rent(1); - buf1.FullMemory.Span[0] = 0x11; - buf1.Length = 1; - state.Write(buf1); - - var buf2 = TransportBuffer.Rent(1); - buf2.FullMemory.Span[0] = 0x22; - buf2.Length = 1; - state.Write(buf2); - - // Defer CompleteWrites - state.CompleteWrites(); - - Assert.Equal(2, state.PendingWriteCount); - Assert.True(state.IsCompleteWritesDeferred); - - // Attach handle - should flush writes then complete them - var stream = new MemoryStream(); - var handle = new StreamHandle(stream); - state.AttachHandle(handle); - - // All writes should be flushed - Assert.Equal(0, state.PendingWriteCount); - Assert.Equal(2, stream.Length); - - // CompleteWrites should have been called, transitioning to HalfClosedWrite - Assert.Equal(StreamPhase.HalfClosedWrite, state.Phase); - Assert.False(state.IsCompleteWritesDeferred); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Quic/QuicTransportEventSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/QuicTransportEventSpec.cs deleted file mode 100644 index 8168d3cfe..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/QuicTransportEventSpec.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Net; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; -using Servus.Akka.Transport.Quic.Client; -using QuicInboundStreamAccepted = Servus.Akka.Transport.Quic.InboundStreamAccepted; - -namespace Servus.Akka.Tests.Transport.Quic; - -public sealed class QuicTransportEventSpec -{ - private QuicConnectionHandle CreateTestConnectionHandle() => - new( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - - [Fact(Timeout = 5000)] - public void ConnectionLeaseAcquired_should_implement_IQuicTransportEvent() - { - var handle = CreateTestConnectionHandle(); - var lease = new QuicConnectionLease(handle, 10); - var evt = new ConnectionLeaseAcquired(lease); - - Assert.Same(lease, evt.Lease); - } - - [Fact(Timeout = 5000)] - public void StreamLeaseAcquired_should_implement_IQuicTransportEvent() - { - var stream = new MemoryStream(); - var handle = new StreamHandle(stream); - const long streamId = 42L; - - var evt = new StreamLeaseAcquired(handle, streamId); - - Assert.Same(handle, evt.Handle); - Assert.Equal(streamId, evt.StreamId); - } - - [Fact(Timeout = 5000)] - public void AcquisitionFailed_should_implement_IQuicTransportEvent() - { - var error = new InvalidOperationException("Test error"); - var evt = new AcquisitionFailed(error); - - Assert.Same(error, evt.Error); - } - - [Fact(Timeout = 5000)] - public void InboundData_should_implement_IQuicTransportEvent() - { - var buffer = TransportBuffer.Rent(16); - try - { - const long streamId = 123L; - const int gen = 5; - - var evt = new InboundData(buffer, streamId, gen); - - Assert.NotNull(evt.Buffer); - Assert.Equal(streamId, evt.StreamId); - Assert.Equal(gen, evt.Gen); - } - finally - { - buffer.Dispose(); - } - } - - [Fact(Timeout = 5000)] - public void InboundStreamAccepted_should_implement_IQuicTransportEvent() - { - var stream = new MemoryStream(); - const long streamId = 999L; - - var evt = new QuicInboundStreamAccepted(stream, streamId); - - Assert.Same(stream, evt.Stream); - Assert.Equal(streamId, evt.StreamId); - } - - [Fact(Timeout = 5000)] - public void InboundComplete_should_implement_IQuicTransportEvent() - { - const DisconnectReason reason = DisconnectReason.Graceful; - const int gen = 3; - const long streamId = 456L; - - var evt = new InboundComplete(reason, gen, streamId); - - Assert.Equal(reason, evt.Reason); - Assert.Equal(gen, evt.Gen); - Assert.Equal(streamId, evt.StreamId); - } - - [Fact(Timeout = 5000)] - public void InboundPumpFailed_should_implement_IQuicTransportEvent() - { - var error = new TimeoutException("Pump failed"); - const long streamId = 789L; - - var evt = new InboundPumpFailed(error, streamId); - - Assert.Same(error, evt.Error); - Assert.Equal(streamId, evt.StreamId); - } - - [Fact(Timeout = 5000)] - public void OutboundWriteDone_should_implement_IQuicTransportEvent() - { - const long streamId = 321L; - - var evt = new OutboundWriteDone(streamId); - - Assert.Equal(streamId, evt.StreamId); - } - - [Fact(Timeout = 5000)] - public void OutboundWriteFailed_should_implement_IQuicTransportEvent() - { - var error = new IOException("Write failed"); - const long streamId = 654L; - - var evt = new OutboundWriteFailed(error, streamId); - - Assert.Same(error, evt.Error); - Assert.Equal(streamId, evt.StreamId); - } - - [Fact(Timeout = 5000)] - public void MigrationDetected_should_implement_IQuicTransportEvent() - { - var oldEndPoint = new IPEndPoint(IPAddress.Loopback, 8000); - var newEndPoint = new IPEndPoint(IPAddress.Loopback, 8001); - - var evt = new MigrationDetected(oldEndPoint, newEndPoint); - - Assert.Same(oldEndPoint, evt.OldEndPoint); - Assert.Same(newEndPoint, evt.NewEndPoint); - } - -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Quic/StreamHandleSpec.cs b/src/Servus.Akka.Tests/Transport/Quic/StreamHandleSpec.cs deleted file mode 100644 index 21c99c9e3..000000000 --- a/src/Servus.Akka.Tests/Transport/Quic/StreamHandleSpec.cs +++ /dev/null @@ -1,135 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; - -namespace Servus.Akka.Tests.Transport.Quic; - -[Collection("TransportBuffer")] -public sealed class StreamHandleSpec -{ - [Fact(Timeout = 5000)] - public void Write_should_write_buffer_to_stream() - { - var ms = new MemoryStream(); - var handle = new StreamHandle(ms); - - var buffer = TransportBuffer.Rent(16); - buffer.FullMemory.Span[0] = 0xAA; - buffer.FullMemory.Span[1] = 0xBB; - buffer.Length = 2; - - handle.Write(buffer); - - Assert.Equal(2, ms.Position); - Assert.Equal(0xAA, ms.GetBuffer()[0]); - Assert.Equal(0xBB, ms.GetBuffer()[1]); - } - - [Fact(Timeout = 5000)] - public async Task ReadAsync_should_read_from_stream() - { - var ms = new MemoryStream([0x01, 0x02, 0x03]); - var handle = new StreamHandle(ms); - - var buf = new byte[16]; - var read = await handle.ReadAsync(buf, CancellationToken.None); - - Assert.Equal(3, read); - Assert.Equal(0x01, buf[0]); - } - - [Fact(Timeout = 5000)] - public void CompleteWrites_should_not_throw() - { - var handle = new StreamHandle(Stream.Null); - handle.CompleteWrites(); - } - - [Fact(Timeout = 5000)] - public void Write_should_write_and_dispose_buffer() - { - var ms = new MemoryStream(); - var handle = new StreamHandle(ms); - - var buffer = TransportBuffer.Rent(16); - buffer.FullMemory.Span[0] = 0x11; - buffer.FullMemory.Span[1] = 0x22; - buffer.FullMemory.Span[2] = 0x33; - buffer.FullMemory.Span[3] = 0x44; - buffer.Length = 4; - - handle.Write(buffer); - - Assert.Equal(4, ms.Length); - Assert.Equal(0x11, ms.GetBuffer()[0]); - Assert.Equal(0x22, ms.GetBuffer()[1]); - Assert.Equal(0x33, ms.GetBuffer()[2]); - Assert.Equal(0x44, ms.GetBuffer()[3]); - - Assert.Throws(() => _ = buffer.Memory); - } - - [Fact(Timeout = 5000)] - public void Write_should_write_multiple_bytes_and_dispose_buffer() - { - var ms = new MemoryStream(); - var handle = new StreamHandle(ms); - - var buffer = TransportBuffer.Rent(16); - buffer.FullMemory.Span[0] = 0x55; - buffer.FullMemory.Span[1] = 0x66; - buffer.FullMemory.Span[2] = 0x77; - buffer.Length = 3; - - handle.Write(buffer); - - Assert.Equal(3, ms.Length); - Assert.Equal(0x55, ms.GetBuffer()[0]); - Assert.Equal(0x66, ms.GetBuffer()[1]); - Assert.Equal(0x77, ms.GetBuffer()[2]); - - Assert.Throws(() => _ = buffer.Memory); - } - - [Fact(Timeout = 5000)] - public void Abort_on_non_QuicStream_should_not_throw() - { - var ms = new MemoryStream(); - var handle = new StreamHandle(ms); - - handle.Abort(0); - handle.Abort(42); - } - - [Fact(Timeout = 5000)] - public void CompleteWrites_on_non_QuicStream_should_not_throw() - { - var ms = new MemoryStream(); - var handle = new StreamHandle(ms); - - handle.CompleteWrites(); - } - - [Fact(Timeout = 5000)] - public async Task DisposeAsync_should_dispose_underlying_stream() - { - var ms = new MemoryStream([0x01, 0x02, 0x03]); - var handle = new StreamHandle(ms); - - await handle.DisposeAsync(); - - Assert.Throws(() => _ = ms.ReadByte()); - } - - [Fact(Timeout = 5000)] - public async Task ReadAsync_should_return_zero_on_empty_stream() - { - var ms = new MemoryStream(); - ms.Position = 0; - var handle = new StreamHandle(ms); - - var buf = new byte[16]; - var read = await handle.ReadAsync(buf, CancellationToken.None); - - Assert.Equal(0, read); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/ServusExtensionsSpec.cs b/src/Servus.Akka.Tests/Transport/ServusExtensionsSpec.cs deleted file mode 100644 index 6aadc50a6..000000000 --- a/src/Servus.Akka.Tests/Transport/ServusExtensionsSpec.cs +++ /dev/null @@ -1,528 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; -using Servus.Akka.Transport; -using Servus.Core.Diagnostics; - -namespace Servus.Akka.Tests.Transport; - -public sealed class ServusExtensionsSpec -{ - [Fact(Timeout = 5000)] - public void DnsLookupDuration_should_create_histogram_on_first_call() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram1 = metrics.DnsLookupDuration(); - - Assert.NotNull(histogram1); - Assert.Equal("dns.lookup.duration", histogram1.Name); - } - - [Fact(Timeout = 5000)] - public void DnsLookupDuration_should_return_cached_histogram_on_second_call() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram1 = metrics.DnsLookupDuration(); - var histogram2 = metrics.DnsLookupDuration(); - - Assert.Same(histogram1, histogram2); - } - - [Fact(Timeout = 5000)] - public void DnsLookupDuration_should_create_histogram_with_correct_unit() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram = metrics.DnsLookupDuration(); - - Assert.Equal("s", histogram.Unit); - } - - [Fact(Timeout = 5000)] - public void DnsLookupDuration_should_create_histogram_with_description() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram = metrics.DnsLookupDuration(); - - Assert.Equal("Duration of DNS lookups in seconds", histogram.Description); - } - - [Fact(Timeout = 5000)] - public void SocketConnectDuration_should_create_histogram_on_first_call() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram1 = metrics.SocketConnectDuration(); - - Assert.NotNull(histogram1); - Assert.Equal("network.socket.connect.duration", histogram1.Name); - } - - [Fact(Timeout = 5000)] - public void SocketConnectDuration_should_return_cached_histogram_on_second_call() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram1 = metrics.SocketConnectDuration(); - var histogram2 = metrics.SocketConnectDuration(); - - Assert.Same(histogram1, histogram2); - } - - [Fact(Timeout = 5000)] - public void SocketConnectDuration_should_create_histogram_with_correct_unit() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram = metrics.SocketConnectDuration(); - - Assert.Equal("s", histogram.Unit); - } - - [Fact(Timeout = 5000)] - public void SocketConnectDuration_should_create_histogram_with_description() - { - var meter = new Meter("test-meter"); - var metrics = CreateServusMetrics(meter); - - var histogram = metrics.SocketConnectDuration(); - - Assert.Equal("Duration of socket connect operations in seconds", histogram.Description); - } - - [Fact(Timeout = 5000)] - public void StartDnsLookup_should_return_null_when_no_listeners() - { - var source = new ActivitySource("test-source"); - var trace = CreateServusTrace(source); - - var activity = trace.StartDnsLookup("example.com"); - - Assert.Null(activity); - } - - [Fact(Timeout = 5000)] - public void StartDnsLookup_should_return_activity_when_listeners_exist() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartDnsLookup("example.com"); - - Assert.NotNull(activity); - Assert.Equal("dns.lookup", activity.OperationName); - Assert.Equal(ActivityKind.Client, activity.Kind); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartDnsLookup_should_set_hostname_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartDnsLookup("example.com"); - - Assert.NotNull(activity); - Assert.Equal("example.com", activity.GetTagItem("dns.question.name")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartDnsLookup_should_set_different_hostnames() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity1 = trace.StartDnsLookup("example.com"); - var activity2 = trace.StartDnsLookup("test.org"); - - Assert.Equal("example.com", activity1?.GetTagItem("dns.question.name")); - Assert.Equal("test.org", activity2?.GetTagItem("dns.question.name")); - activity1?.Dispose(); - activity2?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetDnsAnswers_should_set_answers_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var answers = new[] { "192.0.2.1", "192.0.2.2" }; - trace.SetDnsAnswers(activity, answers); - - Assert.Equal("192.0.2.1,192.0.2.2", activity.GetTagItem("dns.answers")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetDnsAnswers_should_set_answer_count_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var answers = new[] { "192.0.2.1", "192.0.2.2", "192.0.2.3" }; - trace.SetDnsAnswers(activity, answers); - - Assert.Equal(3, activity.GetTagItem("dns.answer.count")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetDnsAnswers_should_handle_single_answer() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var answers = new[] { "192.0.2.1" }; - trace.SetDnsAnswers(activity, answers); - - Assert.Equal("192.0.2.1", activity.GetTagItem("dns.answers")); - Assert.Equal(1, activity.GetTagItem("dns.answer.count")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetDnsAnswers_should_handle_empty_answers() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var answers = Array.Empty(); - trace.SetDnsAnswers(activity, answers); - - Assert.Equal(string.Empty, activity.GetTagItem("dns.answers")); - Assert.Equal(0, activity.GetTagItem("dns.answer.count")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_return_null_when_no_listeners() - { - var source = new ActivitySource("test-source"); - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("192.0.2.1", 80, "tcp", "ipv4"); - - Assert.Null(activity); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_return_activity_when_listeners_exist() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("192.0.2.1", 8080, "tcp", "ipv4"); - - Assert.NotNull(activity); - Assert.Equal("network.socket.connect", activity.OperationName); - Assert.Equal(ActivityKind.Client, activity.Kind); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_set_address_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("192.0.2.1", 8080, "tcp", "ipv4"); - - Assert.Equal("192.0.2.1", activity?.GetTagItem("network.peer.address")); - activity?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_set_port_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("192.0.2.1", 8080, "tcp", "ipv4"); - - Assert.Equal(8080, activity?.GetTagItem("network.peer.port")); - activity?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_set_transport_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("192.0.2.1", 8080, "tcp", "ipv4"); - - Assert.Equal("tcp", activity?.GetTagItem("network.transport")); - activity?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_set_network_type_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("192.0.2.1", 8080, "tcp", "ipv4"); - - Assert.Equal("ipv4", activity?.GetTagItem("network.type")); - activity?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_handle_ipv6() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("::1", 443, "tcp", "ipv6"); - - Assert.Equal("::1", activity?.GetTagItem("network.peer.address")); - Assert.Equal(443, activity?.GetTagItem("network.peer.port")); - Assert.Equal("ipv6", activity?.GetTagItem("network.type")); - activity?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_handle_hostname() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("example.com", 80, "tcp", "ipv4"); - - Assert.Equal("example.com", activity?.GetTagItem("network.peer.address")); - activity?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_handle_different_transports() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var tcpActivity = trace.StartSocketConnect("192.0.2.1", 80, "tcp", "ipv4"); - var udpActivity = trace.StartSocketConnect("192.0.2.1", 53, "udp", "ipv4"); - - Assert.Equal("tcp", tcpActivity?.GetTagItem("network.transport")); - Assert.Equal("udp", udpActivity?.GetTagItem("network.transport")); - tcpActivity?.Dispose(); - udpActivity?.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartSocketConnect_should_return_null_when_source_starts_activity_returns_null() - { - var source = new ActivitySource("test-source"); - using var listener = new ActivityListener - { - ShouldListenTo = _ => true, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.None, - }; - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - - var activity = trace.StartSocketConnect("192.0.2.1", 8080, "tcp", "ipv4"); - - Assert.Null(activity); - } - - [Fact(Timeout = 5000)] - public void SetError_should_set_error_status() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var exception = new InvalidOperationException("Test error"); - trace.SetError(activity, exception); - - Assert.Equal(ActivityStatusCode.Error, activity.Status); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetError_should_set_error_status_description() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var exception = new InvalidOperationException("Test error message"); - trace.SetError(activity, exception); - - Assert.Equal("Test error message", activity.StatusDescription); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetError_should_set_error_type_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var exception = new InvalidOperationException("Test error"); - trace.SetError(activity, exception); - - Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetError_should_set_exception_message_tag() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var exception = new InvalidOperationException("Detailed error message"); - trace.SetError(activity, exception); - - Assert.Equal("Detailed error message", activity.GetTagItem("exception.message")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetError_should_handle_different_exception_types() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartDnsLookup("example.com"); - Assert.NotNull(activity); - - var exception = new ArgumentNullException(nameof(activity), "Parameter is null"); - trace.SetError(activity, exception); - - Assert.Equal(typeof(ArgumentNullException).FullName, activity.GetTagItem("error.type")); - activity.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SetError_should_work_on_socket_connect_activity() - { - var source = new ActivitySource("test-source"); - using var listener = CreateActivityListener(); - ActivitySource.AddActivityListener(listener); - - var trace = CreateServusTrace(source); - var activity = trace.StartSocketConnect("192.0.2.1", 8080, "tcp", "ipv4"); - Assert.NotNull(activity); - - var exception = new IOException("Connection refused"); - trace.SetError(activity, exception); - - Assert.Equal(ActivityStatusCode.Error, activity.Status); - Assert.Equal("Connection refused", activity.StatusDescription); - Assert.Equal(typeof(IOException).FullName, activity.GetTagItem("error.type")); - activity.Dispose(); - } - - private static ServusMetrics CreateServusMetrics(Meter _) - { - return (ServusMetrics)Activator.CreateInstance( - typeof(ServusMetrics), - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, - null, null, null)!; - } - - private static ServusTrace CreateServusTrace(ActivitySource _) - { - return (ServusTrace)Activator.CreateInstance( - typeof(ServusTrace), - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, - null, null, null)!; - } - - private static ActivityListener CreateActivityListener() - { - return new ActivityListener - { - ShouldListenTo = _ => true, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - }; - } -} diff --git a/src/Servus.Akka.Tests/Transport/StreamDirectionSpec.cs b/src/Servus.Akka.Tests/Transport/StreamDirectionSpec.cs deleted file mode 100644 index c1305f7b6..000000000 --- a/src/Servus.Akka.Tests/Transport/StreamDirectionSpec.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class StreamDirectionSpec -{ - [Fact(Timeout = 5000)] - public void StreamDirection_should_have_two_values() - { - var values = Enum.GetValues(); - - Assert.Equal(2, values.Length); - } - - [Fact(Timeout = 5000)] - public void StreamDirection_should_contain_Unidirectional() - { - Assert.True(Enum.IsDefined(StreamDirection.Unidirectional)); - } - - [Fact(Timeout = 5000)] - public void StreamDirection_should_contain_Bidirectional() - { - Assert.True(Enum.IsDefined(StreamDirection.Bidirectional)); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/AbruptCloseExceptionSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/AbruptCloseExceptionSpec.cs deleted file mode 100644 index bed25cebc..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/AbruptCloseExceptionSpec.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class AbruptCloseExceptionSpec -{ - [Fact(Timeout = 5000)] - public void AbruptCloseException_should_have_expected_message() - { - var ex = new AbruptCloseException(); - - Assert.Equal("Connection closed abruptly.", ex.Message); - } - - [Fact(Timeout = 5000)] - public void AbruptCloseException_should_derive_from_exception() - { - var ex = new AbruptCloseException(); - - Assert.IsAssignableFrom(ex); - } - - [Fact(Timeout = 5000)] - public void AbruptCloseException_should_have_null_inner_exception() - { - var ex = new AbruptCloseException(); - - Assert.Null(ex.InnerException); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/ClientByteMoverSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/ClientByteMoverSpec.cs deleted file mode 100644 index 7921a87b5..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/ClientByteMoverSpec.cs +++ /dev/null @@ -1,538 +0,0 @@ -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class ClientByteMoverSpec -{ - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_complete_on_stream_read() - { - var stream = new MemoryStream([0x42], writable: false); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_write_data_to_inbound_channel() - { - var stream = new MemoryStream([0xAB, 0xCD], writable: false); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - - Assert.True(state.InboundReader.TryRead(out var buf)); - Assert.Equal(2, buf.Length); - Assert.Equal(0xAB, buf.Span[0]); - Assert.Equal(0xCD, buf.Span[1]); - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_drain_outbound_channel_to_stream() - { - var capturedWrites = new List(); - var stream = new CapturingStream(capturedWrites); - var state = new ClientState(stream); - - WriteToChannel(state, 100, 0x11); - WriteToChannel(state, 100, 0x22); - WriteToChannel(state, 100, 0x33); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - var totalBytes = capturedWrites.Sum(w => w.Length); - Assert.Equal(300, totalBytes); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_write_large_buffers_to_stream() - { - var capturedWrites = new List(); - var stream = new CapturingStream(capturedWrites); - var state = new ClientState(stream); - - WriteToChannel(state, 33 * 1024, 0xAA); - WriteToChannel(state, 100, 0xBB); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - var totalBytes = capturedWrites.Sum(w => w.Length); - Assert.Equal(33 * 1024 + 100, totalBytes); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_cancellation() - { - var stream = new MemoryStream([0x42], writable: false); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_complete_channel_on_eof() - { - var stream = new MemoryStream([], writable: false); - var state = new ClientState(stream); - var closeCalled = false; - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => closeCalled = true, cts.Token); - - Assert.True(closeCalled); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_complete_channel_with_exception_on_read_error() - { - var stream = new FailingStream(); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - - await Assert.ThrowsAsync(async () => - { - await state.InboundReader.WaitToReadAsync(cts.Token); - }); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_invoke_on_writes_complete_callback() - { - var callbackInvoked = false; - var stream = new MemoryStream(); - var state = new ClientState(stream) - { - OnWritesComplete = () => { callbackInvoked = true; } - }; - - WriteToChannel(state, 10, 0x00); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - Assert.True(callbackInvoked); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_drain_write_exception() - { - var stream = new FailingStream(); - var state = new ClientState(stream); - - WriteToChannel(state, 10, 0x00); - state.OutboundWriter.TryComplete(); - - var onCloseCalled = false; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { onCloseCalled = true; }, cts.Token); - - Assert.True(onCloseCalled); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_alternating_large_small_buffers() - { - var capturedWrites = new List(); - var stream = new CapturingStream(capturedWrites); - var state = new ClientState(stream); - - WriteToChannel(state, 33 * 1024, 0xAA); - WriteToChannel(state, 100, 0xBB); - WriteToChannel(state, 33 * 1024, 0xCC); - WriteToChannel(state, 100, 0xDD); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - var totalBytes = capturedWrites.Sum(w => w.Length); - Assert.Equal(2 * (33 * 1024) + 200, totalBytes); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_not_invoke_on_writes_complete_on_error() - { - var callbackInvoked = false; - var stream = new FailingStream(); - var state = new ClientState(stream) - { - OnWritesComplete = () => { callbackInvoked = true; } - }; - - WriteToChannel(state, 10, 0x00); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - Assert.False(callbackInvoked); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_not_invoke_on_writes_complete_on_cancellation() - { - var callbackInvoked = false; - var stream = new SlowStream(); - var state = new ClientState(stream) - { - OnWritesComplete = () => { callbackInvoked = true; } - }; - - WriteToChannel(state, 10, 0x00); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - Assert.False(callbackInvoked); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_many_small_buffers() - { - var capturedWrites = new List(); - var stream = new CapturingStream(capturedWrites); - var state = new ClientState(stream); - - for (var i = 0; i < 200; i++) - { - WriteToChannel(state, 100, (byte)(i % 256)); - } - - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - var totalBytes = capturedWrites.Sum(w => w.Length); - Assert.Equal(20_000, totalBytes); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_call_on_close_exactly_once_on_read_error() - { - var stream = new FailingStream(); - var state = new ClientState(stream); - - var closeCount = 0; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => Interlocked.Increment(ref closeCount), cts.Token); - - Assert.Equal(1, closeCount); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_drain_pipe_to_channel_with_abrupt_close() - { - var stream = new MemoryStream([0xAA, 0xBB], writable: false); - var state = new ClientState(stream); - var closeCount = 0; - - var ct = TestContext.Current.CancellationToken; - var task = Task.Run(async () => - { - await Task.Delay(50, ct); - try - { - await state.InboundPipe.Writer.CompleteAsync(new AbruptCloseException()); - } - catch - { - // noop - writer might already be completed - } - }, ct); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => Interlocked.Increment(ref closeCount), cts.Token); - await task; - - Assert.Equal(1, closeCount); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_drain_pipe_to_channel_generic_exception() - { - var stream = new MemoryStream([0xAA, 0xBB], writable: false); - var state = new ClientState(stream); - var closeCount = 0; - - var ct = TestContext.Current.CancellationToken; - var task = Task.Run(async () => - { - await Task.Delay(50, ct); - try - { - await state.InboundPipe.Writer.CompleteAsync(new InvalidOperationException("Test error")); - } - catch - { - // noop - writer might already be completed - } - }, ct); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => Interlocked.Increment(ref closeCount), cts.Token); - await task; - - Assert.Equal(1, closeCount); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_read_final_data_after_pipe_completion() - { - var stream = new MemoryStream([0xAA, 0xBB, 0xCC], writable: false); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - - Assert.True(state.InboundReader.TryRead(out var buf)); - Assert.Equal(3, buf.Length); - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_drain_pipe_to_stream_with_multi_segment_buffer() - { - var capturedWrites = new List(); - var stream = new CapturingStream(capturedWrites); - var state = new ClientState(stream); - - WriteToChannel(state, 100, 0x11); - WriteToChannel(state, 100, 0x22); - WriteToChannel(state, 100, 0x33); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - var totalBytes = capturedWrites.Sum(w => w.Length); - Assert.Equal(300, totalBytes); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_drain_pipe_to_stream_write_cancellation() - { - var stream = new SlowStream(); - var state = new ClientState(stream); - var closeCount = 0; - - WriteToChannel(state, 100, 0x44); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); - - await ClientByteMover.MoveChannelToStream(state, () => Interlocked.Increment(ref closeCount), cts.Token); - - Assert.Equal(1, closeCount); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_fill_pipe_from_channel_generic_exception() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - WriteToChannel(state, 10, 0x00); - state.OutboundWriter.TryComplete(new InvalidOperationException("Channel error")); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - Assert.True(stream.Length > 0); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_complete_channel_with_abrupt_exception_on_drain_error() - { - var stream = new FailingStream(); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - - // Verify channel is completed with AbruptCloseException - var exceptionThrown = false; - try - { - await state.InboundReader.WaitToReadAsync(TestContext.Current.CancellationToken); - } - catch (AbruptCloseException) - { - exceptionThrown = true; - } - - Assert.True(exceptionThrown); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_operation_cancelled_on_fill_pipe_from_stream() - { - var stream = new MemoryStream([0xAA, 0xBB, 0xCC, 0xDD], writable: false); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(); - var ct = TestContext.Current.CancellationToken; - var fillTask = Task.Run(async () => - { - await Task.Delay(100, ct); - cts.Cancel(); - }, ct); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - await fillTask; - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_io_exception_on_fill_pipe_from_stream() - { - var stream = new ThrowingReadStream(); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveStreamToChannel(state, () => { }, cts.Token); - - // Should complete with AbruptCloseException on channel - var exceptionThrown = false; - try - { - await state.InboundReader.WaitToReadAsync(TestContext.Current.CancellationToken); - } - catch (AbruptCloseException) - { - exceptionThrown = true; - } - - Assert.True(exceptionThrown); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_pipe_writer_backpressure_on_flush() - { - var capturedWrites = new List(); - var stream = new CapturingStream(capturedWrites); - var state = new ClientState(stream); - - WriteToChannel(state, 1024 * 1024 + 100, 0xFF); // Exceed pause threshold - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - var totalBytes = capturedWrites.Sum(w => w.Length); - Assert.Equal(1024 * 1024 + 100, totalBytes); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_operation_cancelled_on_fill_pipe_from_channel() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - using var cts = new CancellationTokenSource(); - var ct = TestContext.Current.CancellationToken; - var writeTask = Task.Run(async () => - { - await Task.Delay(50, ct); - cts.Cancel(); - }, ct); - - // Write data before cancellation - var buf = TransportBuffer.Rent(100); - buf.Length = 100; - state.OutboundWriter.TryWrite(buf); - - // Cancel while waiting for more data - cts.CancelAfter(TimeSpan.FromMilliseconds(100)); - - var drainTask = ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - await Task.WhenAll(drainTask, writeTask); - } - - private static void WriteToChannel(ClientState state, int size, byte fill) - { - var buf = TransportBuffer.Rent(size); - buf.FullMemory.Span[..size].Fill(fill); - buf.Length = size; - state.OutboundWriter.TryWrite(buf); - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_multi_segment_buffer_with_error_on_write() - { - var failingStream = new FailingStream(); - var state = new ClientState(failingStream); - - // Write multiple buffers to create multi-segment potential in the pipe - WriteToChannel(state, 100, 0x11); - WriteToChannel(state, 100, 0x22); - WriteToChannel(state, 100, 0x33); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - await ClientByteMover.MoveChannelToStream(state, () => { }, cts.Token); - - // Stream write should have failed - } - - [Fact(Timeout = 5000)] - public async Task ClientByteMover_should_handle_cancelled_write_in_drain_pipe_to_stream() - { - var slowStream = new SlowStream(); - var state = new ClientState(slowStream); - var closeCount = 0; - - WriteToChannel(state, 100, 0x99); - state.OutboundWriter.TryComplete(); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); - - await ClientByteMover.MoveChannelToStream(state, () => Interlocked.Increment(ref closeCount), cts.Token); - - Assert.Equal(1, closeCount); - } - - private sealed class ThrowingReadStream : MemoryStream - { - public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { - return new ValueTask(Task.FromException(new IOException("Simulated read failure"))); - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/ConnectTunnelSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/ConnectTunnelSpec.cs deleted file mode 100644 index ad2aa09ea..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/ConnectTunnelSpec.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.IO.Pipelines; -using System.Net; -using System.Text; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class ConnectTunnelSpec -{ - private const string TargetHost = "example.com"; - private const int TargetPort = 443; - - [Fact(Timeout = 10_000)] - public async Task Tunnel_should_send_correct_CONNECT_request() - { - var (clientStream, serverStream) = CreateDuplexPipe(); - - var tunnelTask = TlsClientProvider.EstablishConnectTunnelAsync( - clientStream, TargetHost, TargetPort, - new SimpleProxy(), null, TestContext.Current.CancellationToken); - - var request = await ReadRequestAsync(serverStream); - await WriteResponseAsync(serverStream, "HTTP/1.1 200 Connection Established\r\n\r\n"); - await tunnelTask; - - Assert.StartsWith($"CONNECT {TargetHost}:{TargetPort} HTTP/1.1\r\n", request); - Assert.Contains($"Host: {TargetHost}:{TargetPort}\r\n", request); - Assert.EndsWith("\r\n\r\n", request); - } - - [Fact(Timeout = 5000)] - public async Task Tunnel_should_succeed_on_200_response() - { - var (clientStream, serverStream) = CreateDuplexPipe(); - - var tunnelTask = TlsClientProvider.EstablishConnectTunnelAsync( - clientStream, TargetHost, TargetPort, - new SimpleProxy(), null, TestContext.Current.CancellationToken); - - await ReadRequestAsync(serverStream); - await WriteResponseAsync(serverStream, "HTTP/1.1 200 Connection Established\r\n\r\n"); - - await tunnelTask; - } - - [Fact(Timeout = 5000)] - public async Task Tunnel_should_throw_on_non_200_response() - { - var (clientStream, serverStream) = CreateDuplexPipe(); - - var tunnelTask = TlsClientProvider.EstablishConnectTunnelAsync( - clientStream, TargetHost, TargetPort, - new SimpleProxy(), null, TestContext.Current.CancellationToken); - - await ReadRequestAsync(serverStream); - await WriteResponseAsync(serverStream, "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); - - var ex = await Assert.ThrowsAsync(() => tunnelTask); - Assert.Contains("407", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task Tunnel_should_throw_on_proxy_close() - { - var (clientStream, serverStream) = CreateDuplexPipe(); - - var tunnelTask = TlsClientProvider.EstablishConnectTunnelAsync( - clientStream, TargetHost, TargetPort, - new SimpleProxy(), null, TestContext.Current.CancellationToken); - - await ReadRequestAsync(serverStream); - await serverStream.DisposeAsync(); - - await Assert.ThrowsAsync(() => tunnelTask); - } - - [Fact(Timeout = 5000)] - public async Task Tunnel_should_include_proxy_auth_header() - { - var (clientStream, serverStream) = CreateDuplexPipe(); - - var credentials = new NetworkCredential("user", "pass"); - var proxy = new SimpleProxy(credentials); - - var tunnelTask = TlsClientProvider.EstablishConnectTunnelAsync( - clientStream, TargetHost, TargetPort, - proxy, null, TestContext.Current.CancellationToken); - - var request = await ReadRequestAsync(serverStream); - await WriteResponseAsync(serverStream, "HTTP/1.1 200 OK\r\n\r\n"); - await tunnelTask; - - var expectedEncoded = Convert.ToBase64String("user:pass"u8.ToArray()); - Assert.Contains($"Proxy-Authorization: Basic {expectedEncoded}\r\n", request); - } - - [Fact(Timeout = 5000)] - public async Task Tunnel_should_accept_http10_200_response() - { - var (clientStream, serverStream) = CreateDuplexPipe(); - - var tunnelTask = TlsClientProvider.EstablishConnectTunnelAsync( - clientStream, TargetHost, TargetPort, - new SimpleProxy(), null, TestContext.Current.CancellationToken); - - await ReadRequestAsync(serverStream); - await WriteResponseAsync(serverStream, "HTTP/1.0 200 OK\r\n\r\n"); - - await tunnelTask; - } - - private static (Stream Client, Stream Server) CreateDuplexPipe() - { - var clientToServer = new Pipe(); - var serverToClient = new Pipe(); - - var clientStream = new DuplexPipeStream( - serverToClient.Reader, clientToServer.Writer); - var serverStream = new DuplexPipeStream( - clientToServer.Reader, serverToClient.Writer); - - return (clientStream, serverStream); - } - - private static async Task ReadRequestAsync(Stream serverStream) - { - var buffer = new byte[4096]; - var totalRead = 0; - - while (totalRead < buffer.Length) - { - var read = await serverStream.ReadAsync(buffer.AsMemory(totalRead)); - if (read == 0) - { - break; - } - - totalRead += read; - var text = Encoding.ASCII.GetString(buffer, 0, totalRead); - if (text.Contains("\r\n\r\n")) - { - break; - } - } - - return Encoding.ASCII.GetString(buffer, 0, totalRead); - } - - private static async Task WriteResponseAsync(Stream serverStream, string response) - { - await serverStream.WriteAsync(Encoding.ASCII.GetBytes(response)); - await serverStream.FlushAsync(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/DnsCacheSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/DnsCacheSpec.cs deleted file mode 100644 index eb3ff26ff..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/DnsCacheSpec.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Net; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -[Collection("DnsCache")] -public sealed class DnsCacheSpec : IDisposable -{ - public DnsCacheSpec() - { - DnsCache.Clear(); - } - - public void Dispose() - { - DnsCache.Clear(); - DnsCache.Ttl = TimeSpan.FromSeconds(120); - } - - [Fact(Timeout = 5000)] - public async Task ResolveAsync_should_return_literal_ip_without_dns_lookup() - { - var addresses = await DnsCache.ResolveAsync("127.0.0.1", CancellationToken.None); - - Assert.Single(addresses); - Assert.Equal(IPAddress.Loopback, addresses[0]); - } - - [Fact(Timeout = 5000)] - public async Task ResolveAsync_should_return_ipv6_literal() - { - var addresses = await DnsCache.ResolveAsync("::1", CancellationToken.None); - - Assert.Single(addresses); - Assert.Equal(IPAddress.IPv6Loopback, addresses[0]); - } - - [Fact(Timeout = 10000)] - public async Task ResolveAsync_should_resolve_localhost() - { - var addresses = await DnsCache.ResolveAsync("localhost", CancellationToken.None); - - Assert.NotEmpty(addresses); - } - - [Fact(Timeout = 10000)] - public async Task ResolveAsync_should_cache_results() - { - var first = await DnsCache.ResolveAsync("localhost", CancellationToken.None); - var second = await DnsCache.ResolveAsync("localhost", CancellationToken.None); - - Assert.Same(first, second); - } - - [Fact(Timeout = 10000)] - public async Task ResolveAsync_should_expire_after_ttl() - { - DnsCache.Ttl = TimeSpan.FromMilliseconds(1); - - var first = await DnsCache.ResolveAsync("localhost", CancellationToken.None); - await Task.Delay(100, TestContext.Current.CancellationToken); - var second = await DnsCache.ResolveAsync("localhost", CancellationToken.None); - - Assert.NotSame(first, second); - } - - [Fact(Timeout = 5000)] - public async Task Clear_should_remove_all_entries() - { - await DnsCache.ResolveAsync("127.0.0.1", CancellationToken.None); - DnsCache.Clear(); - - var addresses = await DnsCache.ResolveAsync("127.0.0.1", CancellationToken.None); - Assert.NotNull(addresses); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpClientProviderSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpClientProviderSpec.cs deleted file mode 100644 index ed9ef9a99..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpClientProviderSpec.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -[CollectionDefinition("ClientProvider", DisableParallelization = true)] -public class ClientProviderCollection; - -[Collection("ClientProvider")] -public sealed class TcpClientProviderSpec -{ - [Fact(Timeout = 5000)] - public void TcpClientProvider_should_initialize_with_options() - { - var options = new TcpTransportOptions - { - Host = "localhost", - Port = 8080 - }; - - var provider = new TcpClientProvider(options); - - Assert.Null(provider.RemoteEndPoint); - } - - [Fact(Timeout = 5000)] - public async Task TcpClientProvider_should_dispose_without_socket() - { - var options = new TcpTransportOptions { Host = "localhost", Port = 8080 }; - var provider = new TcpClientProvider(options); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task TcpClientProvider_should_complete_disposal_on_double_dispose() - { - var options = new TcpTransportOptions { Host = "localhost", Port = 8080 }; - var provider = new TcpClientProvider(options); - - await provider.DisposeAsync(); - await provider.DisposeAsync(); - } - - [Fact(Timeout = 10_000)] - public async Task TcpClientProvider_should_resolve_proxy_when_configured() - { - var proxyUri = new Uri("http://proxy.local:8080"); - var proxy = new TestProxy(proxyUri); - - var options = new TcpTransportOptions - { - Host = "example.com", - Port = 443, - UseProxy = true, - Proxy = proxy - }; - - var provider = new TcpClientProvider(options); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await Assert.ThrowsAnyAsync(async () => - await provider.GetStreamAsync(cts.Token)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task TcpClientProvider_should_bypass_proxy_when_bypassed() - { - var proxy = new TestProxy(null, bypassedHost: "example.com"); - - var options = new TcpTransportOptions - { - Host = "localhost", - Port = 1, - UseProxy = true, - Proxy = proxy - }; - - var provider = new TcpClientProvider(options); - - await Assert.ThrowsAsync(async () => - await provider.GetStreamAsync(CancellationToken.None)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task TcpClientProvider_should_not_use_proxy_when_disabled() - { - var proxy = new TestProxy(new Uri("http://proxy.local:8080")); - - var options = new TcpTransportOptions - { - Host = "localhost", - Port = 1, - UseProxy = false, - Proxy = proxy - }; - - var provider = new TcpClientProvider(options); - - await Assert.ThrowsAsync(async () => - await provider.GetStreamAsync(CancellationToken.None)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 10_000)] - public async Task TcpClientProvider_should_apply_default_proxy_credentials() - { - var credentials = new NetworkCredential("user", "pass"); - var proxy = new TestProxy(new Uri("http://proxy.local:8080")); - - var options = new TcpTransportOptions - { - Host = "example.com", - Port = 443, - UseProxy = true, - Proxy = proxy, - DefaultProxyCredentials = credentials - }; - - var provider = new TcpClientProvider(options); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await Assert.ThrowsAnyAsync(async () => - await provider.GetStreamAsync(cts.Token)); - - Assert.NotNull(proxy.Credentials); - await provider.DisposeAsync(); - } - - [Fact(Timeout = 10_000)] - public async Task TcpClientProvider_should_not_override_existing_proxy_credentials() - { - var existingCredentials = new NetworkCredential("existing", "existing"); - var defaultCredentials = new NetworkCredential("default", "default"); - var proxy = new TestProxy(new Uri("http://proxy.local:8080"), credentials: existingCredentials); - - var options = new TcpTransportOptions - { - Host = "example.com", - Port = 443, - UseProxy = true, - Proxy = proxy, - DefaultProxyCredentials = defaultCredentials - }; - - var provider = new TcpClientProvider(options); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await Assert.ThrowsAnyAsync(async () => - await provider.GetStreamAsync(cts.Token)); - - Assert.Equal("existing", ((NetworkCredential)proxy.Credentials!).UserName); - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task TcpClientProvider_should_set_socket_options() - { - var options = new TcpTransportOptions - { - Host = "localhost", - Port = 1, - SocketSendBufferSize = 65536, - SocketReceiveBufferSize = 65536 - }; - - var provider = new TcpClientProvider(options); - - await Assert.ThrowsAsync(async () => - await provider.GetStreamAsync(CancellationToken.None)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task TcpClientProvider_should_handle_null_buffer_sizes() - { - var options = new TcpTransportOptions - { - Host = "localhost", - Port = 1, - SocketSendBufferSize = null, - SocketReceiveBufferSize = null - }; - - var provider = new TcpClientProvider(options); - - await Assert.ThrowsAsync(async () => - await provider.GetStreamAsync(CancellationToken.None)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task TcpClientProvider_should_throw_OperationCanceledException_on_timeout() - { - var options = new TcpTransportOptions - { - Host = "192.0.2.1", - Port = 443 - }; - - var provider = new TcpClientProvider(options); - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); - - await Assert.ThrowsAsync(async () => - await provider.GetStreamAsync(cts.Token)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 10_000)] - public async Task GetStreamAsync_should_throw_socket_exception_for_unreachable_host() - { - var options = new TcpTransportOptions - { - Host = "invalid-host-that-does-not-exist-12345.local", - Port = 80 - }; - - var provider = new TcpClientProvider(options); - - await Assert.ThrowsAsync(async () => - { - await provider.GetStreamAsync(CancellationToken.None); - }); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 10_000)] - public async Task GetStreamAsync_should_respect_cancellation_token() - { - var options = new TcpTransportOptions - { - Host = "192.0.2.1", - Port = 443 - }; - - var provider = new TcpClientProvider(options); - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - var exception = - await Assert.ThrowsAnyAsync(async () => { await provider.GetStreamAsync(cts.Token); }); - - Assert.True( - exception is OperationCanceledException, - $"Expected OperationCanceledException or derived type, got {exception.GetType().Name}" - ); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task RemoteEndPoint_should_be_null_before_connect() - { - var options = new TcpTransportOptions - { - Host = "localhost", - Port = 8080 - }; - - var provider = new TcpClientProvider(options); - - Assert.Null(provider.RemoteEndPoint); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 10_000)] - public async Task Disposal_should_be_safe_after_failed_connect() - { - var options = new TcpTransportOptions - { - Host = "invalid-host-that-does-not-exist-xyz.local", - Port = 443 - }; - - var provider = new TcpClientProvider(options); - - await Assert.ThrowsAsync(async () => - await provider.GetStreamAsync(CancellationToken.None)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 30_000)] - public async Task GetStreamAsync_with_custom_buffer_sizes_should_not_throw_on_configuration() - { - var options = new TcpTransportOptions - { - Host = "invalid-host-that-does-not-exist-abc.local", - Port = 443, - SocketSendBufferSize = 131072, - SocketReceiveBufferSize = 131072 - }; - - var provider = new TcpClientProvider(options); - - var exception = await Assert.ThrowsAsync(async () => - { - await provider.GetStreamAsync(CancellationToken.None); - }); - - Assert.NotNull(exception); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 30_000)] - public async Task GetStreamAsync_with_zero_buffer_sizes_should_not_throw_on_configuration() - { - var options = new TcpTransportOptions - { - Host = "invalid-host-that-does-not-exist-def.local", - Port = 443, - SocketSendBufferSize = 0, - SocketReceiveBufferSize = 0 - }; - - var provider = new TcpClientProvider(options); - - var exception = await Assert.ThrowsAsync(async () => - { - await provider.GetStreamAsync(CancellationToken.None); - }); - - Assert.NotNull(exception); - - await provider.DisposeAsync(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionFactorySpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionFactorySpec.cs deleted file mode 100644 index b711d3d17..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionFactorySpec.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class TcpConnectionFactorySpec : IAsyncLifetime -{ - private TcpListener? _listener; - private int _port; - - public ValueTask InitializeAsync() - { - _listener = new TcpListener(IPAddress.Loopback, 0); - _listener.Start(); - _port = ((IPEndPoint)_listener.LocalEndpoint).Port; - return ValueTask.CompletedTask; - } - - public ValueTask DisposeAsync() - { - _listener?.Stop(); - return ValueTask.CompletedTask; - } - - private TcpTransportOptions CreateOptions() => new() - { - Host = "127.0.0.1", - Port = (ushort)_port - }; - - [Fact(Timeout = 5000)] - public async Task EstablishAsync_should_return_live_lease() - { - var factory = new TcpConnectionFactory(); - var options = CreateOptions(); - - using var lease = await factory.EstablishAsync(options, TestContext.Current.CancellationToken); - - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task EstablishAsync_should_throw_on_pre_cancelled_token() - { - var factory = new TcpConnectionFactory(); - var options = CreateOptions(); - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAnyAsync(() => - factory.EstablishAsync(options, cts.Token)); - } - - [Fact(Timeout = 5000)] - public async Task EstablishAsync_should_throw_when_cancelled_during_connect() - { - var factory = new TcpConnectionFactory(); - var options = new TcpTransportOptions - { - Host = "192.0.2.1", - Port = 80, - ConnectTimeout = TimeSpan.FromSeconds(30) - }; - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); - - await Assert.ThrowsAnyAsync(() => - factory.EstablishAsync(options, cts.Token)); - } - - [Fact(Timeout = 5000)] - public async Task EstablishAsync_should_throw_on_connection_refused() - { - _listener!.Stop(); - - var factory = new TcpConnectionFactory(); - var options = CreateOptions(); - - await Assert.ThrowsAnyAsync(() => - factory.EstablishAsync(options, TestContext.Current.CancellationToken)); - } - - [Fact(Timeout = 5000)] - public async Task Disposing_lease_should_mark_it_not_alive() - { - var factory = new TcpConnectionFactory(); - var options = CreateOptions(); - - var lease = await factory.EstablishAsync(options, TestContext.Current.CancellationToken); - - Assert.True(lease.IsAlive()); - - lease.Dispose(); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task EstablishAsync_should_throw_on_unsupported_options() - { - var factory = new TcpConnectionFactory(); - var options = new QuicTransportOptions - { - Host = "127.0.0.1", - Port = (ushort)_port - }; - - await Assert.ThrowsAsync(() => - factory.EstablishAsync(options, TestContext.Current.CancellationToken)); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionManagerActorSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionManagerActorSpec.cs deleted file mode 100644 index 42203e6ef..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionManagerActorSpec.cs +++ /dev/null @@ -1,644 +0,0 @@ -using Akka.Actor; -using Akka.TestKit.Xunit; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class TcpConnectionManagerActorSpec : TestKit -{ - private readonly InMemoryTcpConnectionFactory _factory = new(); - - private static readonly TcpPoolConfig DefaultPoolConfig = new( - MaxConnectionsPerHost: 6, - IdleTimeout: TimeSpan.FromSeconds(5), - ConnectionLifetime: Timeout.InfiniteTimeSpan, - ReuseOnUpstreamFinish: true); - - private static TcpTransportOptions CreateOptions() => new() - { - Host = "127.0.0.1", - Port = 8080 - }; - - private IActorRef CreateActor(PoolConfigRegistry? registry = null) - => Sys.ActorOf(TransportFactory.CreateTcpConnectionManager(_factory, registry ?? new PoolConfigRegistry(DefaultPoolConfig))); - - [Fact(Timeout = 5000)] - public async Task Acquire_should_create_new_connection() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_reuse_idle_connection_when_strategy_allows() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.Same(lease1, lease2); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_not_reuse_when_release_forbids() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: false)); - - AwaitCondition(() => !lease1.IsAlive(), TimeSpan.FromSeconds(2), - TestContext.Current.CancellationToken); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.NotSame(lease1, lease2); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Release_should_return_to_idle_when_can_reuse() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease, CanReuse: true)); - - Assert.True(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task Release_should_dispose_connection_when_cannot_reuse() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease, CanReuse: false)); - - AwaitCondition(() => !lease.IsAlive(), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task EvictIdle_should_remove_expired_connections() - { - var registry = new PoolConfigRegistry(new TcpPoolConfig( - MaxConnectionsPerHost: 6, - IdleTimeout: TimeSpan.FromMilliseconds(50), - ConnectionLifetime: TimeSpan.FromMilliseconds(50), - ReuseOnUpstreamFinish: true)); - var actor = CreateActor(registry); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - Assert.NotSame(lease1, lease2); - - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - actor.Tell(new TcpConnectionManagerActor.Release(lease2, CanReuse: true)); - - await Task.Delay(100, TestContext.Current.CancellationToken); - actor.Tell(TcpConnectionManagerActor.Evict.Instance); - - AwaitCondition(() => !lease1.IsAlive() || !lease2.IsAlive(), TimeSpan.FromSeconds(2), - TestContext.Current.CancellationToken); - - var evictedCount = (!lease1.IsAlive() ? 1 : 0) + (!lease2.IsAlive() ? 1 : 0); - Assert.True(evictedCount >= 1, "At least one idle connection should have been evicted"); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_block_when_per_host_limit_is_full() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var leases = new List(); - for (var i = 0; i < 6; i++) - { - leases.Add(await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken)); - } - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - await Assert.ThrowsAnyAsync(async () => - { - await TcpConnectionManagerActor.AcquireAsync(actor, options, cts.Token); - }); - - foreach (var lease in leases) - { - lease.Dispose(); - } - } - - [Fact(Timeout = 5000)] - public async Task GracefulStop_should_dispose_all_leases() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await actor.GracefulStop(TimeSpan.FromSeconds(5)); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public async Task Release_with_pending_should_hand_off_directly() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var leases = new List(); - for (var i = 0; i < 6; i++) - { - leases.Add(await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken)); - } - - var pendingTask = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - actor.Tell(new TcpConnectionManagerActor.Release(leases[0], CanReuse: true)); - - var handedOff = await pendingTask.WaitAsync(TimeSpan.FromSeconds(3), - TestContext.Current.CancellationToken); - Assert.Same(leases[0], handedOff); - - foreach (var lease in leases.Skip(1)) - { - lease.Dispose(); - } - - handedOff.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Multiple_hosts_should_maintain_separate_pools() - { - var actor = CreateActor(); - var options1 = new TcpTransportOptions { Host = "host1.example.com", Port = 80 }; - var options2 = new TcpTransportOptions { Host = "host2.example.com", Port = 80 }; - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options1, - TestContext.Current.CancellationToken); - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options2, - TestContext.Current.CancellationToken); - - Assert.NotSame(lease1, lease2); - - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - actor.Tell(new TcpConnectionManagerActor.Release(lease2, CanReuse: true)); - - var lease3 = await TcpConnectionManagerActor.AcquireAsync(actor, options1, - TestContext.Current.CancellationToken); - var lease4 = await TcpConnectionManagerActor.AcquireAsync(actor, options2, - TestContext.Current.CancellationToken); - - Assert.Same(lease1, lease3); - Assert.Same(lease2, lease4); - - lease3.Dispose(); - lease4.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_timeout_when_exhausted_and_pending() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var leases = new List(); - for (var i = 0; i < 6; i++) - { - leases.Add(await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken)); - } - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); - var ex = await Assert.ThrowsAnyAsync(async () => - { - await TcpConnectionManagerActor.AcquireAsync(actor, options, cts.Token); - }); - - Assert.NotNull(ex); - - foreach (var lease in leases) - { - lease.Dispose(); - } - } - - [Fact(Timeout = 5000)] - public async Task Release_dead_lease_should_not_crash_actor() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - lease.Dispose(); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - Assert.NotNull(lease2); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Idle_timeout_zero_should_disable_eviction() - { - var registry = new PoolConfigRegistry(new TcpPoolConfig( - MaxConnectionsPerHost: 6, - IdleTimeout: TimeSpan.Zero, - ConnectionLifetime: Timeout.InfiniteTimeSpan, - ReuseOnUpstreamFinish: true)); - var actor = CreateActor(registry); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - actor.Tell(new TcpConnectionManagerActor.Release(lease2, CanReuse: true)); - - await Task.Delay(500, TestContext.Current.CancellationToken); - - Assert.True(lease1.IsAlive() || lease2.IsAlive()); - - if (lease1.IsAlive()) lease1.Dispose(); - if (lease2.IsAlive()) lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_with_already_cancelled_token_should_be_ignored_by_actor() - { - var actor = CreateActor(); - var options = CreateOptions(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAnyAsync(() => - TcpConnectionManagerActor.AcquireAsync(actor, options, cts.Token)); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - Assert.NotNull(lease); - - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Established_with_cancelled_caller_should_release_back_to_pool() - { - var slowFactory = new SlowTcpConnectionFactory(TimeSpan.FromMilliseconds(200)); - var registry = new PoolConfigRegistry(DefaultPoolConfig with { MaxConnectionsPerHost = 1 }); - var actor = Sys.ActorOf(TransportFactory.CreateTcpConnectionManager(slowFactory, registry)); - var options = CreateOptions(); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); - - var task1 = TcpConnectionManagerActor.AcquireAsync(actor, options, cts.Token); - var task2 = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Assert.ThrowsAnyAsync(() => task1); - - var lease = await task2; - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Acquire_should_skip_dead_idle_lease_and_establish_fresh_connection() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - - await Task.Delay(50, TestContext.Current.CancellationToken); - - lease1.Dispose(); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - Assert.NotSame(lease1, lease2); - Assert.True(lease2.IsAlive()); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task EstablishFailed_should_cascade_to_pending_waiter() - { - var failOnce = new FailOnceTcpConnectionFactory(); - var registry = new PoolConfigRegistry(DefaultPoolConfig with { MaxConnectionsPerHost = 1 }); - var actor = Sys.ActorOf(TransportFactory.CreateTcpConnectionManager(failOnce, registry)); - var options = CreateOptions(); - - var task1 = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Task.Delay(10, TestContext.Current.CancellationToken); - - var task2 = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Assert.ThrowsAnyAsync(() => task1); - - var lease = await task2; - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Evicted_idle_connection_should_not_be_reused() - { - var registry = new PoolConfigRegistry(new TcpPoolConfig( - MaxConnectionsPerHost: 6, - IdleTimeout: TimeSpan.FromMilliseconds(50), - ConnectionLifetime: TimeSpan.FromMilliseconds(50), - ReuseOnUpstreamFinish: true)); - var actor = CreateActor(registry); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - Assert.NotNull(lease2); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task OnEvict_should_dispose_dead_leases() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - lease1.Dispose(); - actor.Tell(new TcpConnectionManagerActor.Release(lease2, CanReuse: true)); - - actor.Tell(TcpConnectionManagerActor.Evict.Instance); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - Assert.False(lease1.IsAlive()); - Assert.True(lease2.IsAlive()); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task OnEvict_should_preserve_valid_idle_leases() - { - var registry = new PoolConfigRegistry(new TcpPoolConfig( - MaxConnectionsPerHost: 6, - IdleTimeout: TimeSpan.FromSeconds(5), - ConnectionLifetime: TimeSpan.FromSeconds(5), - ReuseOnUpstreamFinish: true)); - var actor = CreateActor(registry); - var options = CreateOptions(); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease, CanReuse: true)); - - actor.Tell(TcpConnectionManagerActor.Evict.Instance); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - Assert.True(lease.IsAlive()); - - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task OnEstablished_with_cancelled_caller_should_release_back() - { - var slowFactory = new SlowTcpConnectionFactory(TimeSpan.FromMilliseconds(100)); - var registry = new PoolConfigRegistry(DefaultPoolConfig with { MaxConnectionsPerHost = 2 }); - var actor = Sys.ActorOf(TransportFactory.CreateTcpConnectionManager(slowFactory, registry)); - var options = CreateOptions(); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); - var task1 = TcpConnectionManagerActor.AcquireAsync(actor, options, cts.Token); - var task2 = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Assert.ThrowsAnyAsync(() => task1); - - var lease = await task2.WaitAsync(TimeSpan.FromSeconds(3), - TestContext.Current.CancellationToken); - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task OnFailed_should_decrement_establishing_and_serve_pending() - { - var failOnce = new FailOnceTcpConnectionFactory(); - var registry = new PoolConfigRegistry(DefaultPoolConfig with { MaxConnectionsPerHost = 1 }); - var actor = Sys.ActorOf(TransportFactory.CreateTcpConnectionManager(failOnce, registry)); - var options = CreateOptions(); - - var task1 = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Task.Delay(10, TestContext.Current.CancellationToken); - - var task2 = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Assert.ThrowsAnyAsync(() => task1); - - var lease = await task2.WaitAsync(TimeSpan.FromSeconds(3), - TestContext.Current.CancellationToken); - Assert.NotNull(lease); - Assert.True(lease.IsAlive()); - - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Release_dead_unknown_lease_should_not_crash() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - lease.Dispose(); - - actor.Tell(new TcpConnectionManagerActor.Release(lease, CanReuse: true)); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - Assert.NotNull(lease2); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task OnRelease_should_cascade_pending_when_cant_establish() - { - var registry = new PoolConfigRegistry(DefaultPoolConfig with { MaxConnectionsPerHost = 2 }); - var actor = CreateActor(registry); - var options = CreateOptions(); - - var leases = new List(); - for (var i = 0; i < 2; i++) - { - leases.Add(await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken)); - } - - var pendingTask = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await Task.Delay(50, TestContext.Current.CancellationToken); - - actor.Tell(new TcpConnectionManagerActor.Release(leases[0], CanReuse: true)); - - var handed = await pendingTask.WaitAsync(TimeSpan.FromSeconds(3), - TestContext.Current.CancellationToken); - Assert.Same(leases[0], handed); - - leases[1].Dispose(); - handed.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task OnAcquire_should_skip_expired_idle_leases() - { - var registry = new PoolConfigRegistry(new TcpPoolConfig( - MaxConnectionsPerHost: 6, - IdleTimeout: TimeSpan.FromSeconds(5), - ConnectionLifetime: TimeSpan.FromMilliseconds(50), - ReuseOnUpstreamFinish: true)); - var actor = CreateActor(registry); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - - await Task.Delay(100, TestContext.Current.CancellationToken); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.NotSame(lease1, lease2); - Assert.True(lease2.IsAlive()); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task OnAcquire_should_skip_dead_idle_lease_and_create_new() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var lease1 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - actor.Tell(new TcpConnectionManagerActor.Release(lease1, CanReuse: true)); - - lease1.Dispose(); - - var lease2 = await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - Assert.NotSame(lease1, lease2); - Assert.True(lease2.IsAlive()); - - lease2.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task PostStop_should_reject_pending_requests() - { - var actor = CreateActor(); - var options = CreateOptions(); - - var leases = new List(); - for (var i = 0; i < 6; i++) - { - leases.Add(await TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken)); - } - - var pendingTask = TcpConnectionManagerActor.AcquireAsync(actor, options, - TestContext.Current.CancellationToken); - - await actor.GracefulStop(TimeSpan.FromSeconds(2)); - - await Assert.ThrowsAnyAsync(() => pendingTask); - - foreach (var lease in leases) - { - Assert.False(lease.IsAlive()); - } - } - -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionStageSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionStageSpec.cs deleted file mode 100644 index 9ea65583a..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpConnectionStageSpec.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.TestKit.Xunit; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class TcpConnectionStageSpec : TestKit -{ - private readonly IMaterializer _materializer; - private readonly IPoolingStrategy _poolingStrategy; - - public TcpConnectionStageSpec() - { - _materializer = Sys.Materializer(); - _poolingStrategy = new TestPoolingStrategy(); - } - - - [Fact(Timeout = 5000)] - public void Stage_should_materialize_without_error() - { - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, sinkQueue) = Source - .Queue(1, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - Assert.NotNull(sourceQueue); - Assert.NotNull(sinkQueue); - } - - [Fact(Timeout = 5000)] - public void Stage_should_have_correct_shape() - { - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - - Assert.NotNull(stage.Shape); - Assert.Equal("TcpConnection.In", stage.Shape.Inlet.Name); - Assert.Equal("TcpConnection.Out", stage.Shape.Outlet.Name); - } - - [Fact(Timeout = 5000)] - public void Stage_shape_inlet_should_accept_ITransportOutbound() - { - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - - Assert.NotNull(stage.Shape.Inlet); - // Inlet is typed to ITransportOutbound via FlowShape - Assert.IsAssignableFrom>(stage.Shape.Inlet); - } - - [Fact(Timeout = 5000)] - public void Stage_shape_outlet_should_emit_ITransportInbound() - { - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - - Assert.NotNull(stage.Shape.Outlet); - // Outlet is typed to ITransportInbound via FlowShape - Assert.IsAssignableFrom>(stage.Shape.Outlet); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_accept_ConnectTransport() - { - var options = new TcpTransportOptions - { - Host = "127.0.0.1", - Port = 8080 - }; - - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, _) = Source - .Queue(1, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - // Push ConnectTransport onto the stage inlet - await sourceQueue.OfferAsync(new ConnectTransport(options)); - - // Expect Acquire message on TestActor from state machine - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - Assert.Equal("127.0.0.1", msg.Options.Host); - Assert.Equal(8080, msg.Options.Port); - } - - [Fact(Timeout = 10000)] - public async Task Stage_should_queue_inbound_when_outlet_not_pulled() - { - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, sinkQueue) = Source - .Queue(2, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - await sourceQueue.OfferAsync(new ConnectTransport(new TcpTransportOptions - { - Host = "127.0.0.1", - Port = 8080 - })); - - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - - // Verify that the stage can queue inbound items when outlet is not pulled - Assert.NotNull(sinkQueue); - } - - [Fact(Timeout = 10000)] - public async Task Stage_should_handle_downstream_finish_signal() - { - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, _) = Source - .Queue(1, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - // Test that the stage properly initializes and can handle lifecycle - // The OnDownstreamFinish handler is called when downstream cancels - await sourceQueue.OfferAsync(new ConnectTransport(new TcpTransportOptions - { - Host = "127.0.0.1", - Port = 8080 - })); - - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - } - - [Fact(Timeout = 5000)] - public async Task Stage_should_pull_inlet_when_outlet_pulled_and_not_already_pulled() - { - var stage = new TcpConnectionStage(TestActor, _poolingStrategy); - var flow = Flow.FromGraph(stage); - - var (sourceQueue, _) = Source - .Queue(2, OverflowStrategy.Fail) - .ViaMaterialized(flow, Keep.Left) - .ToMaterialized(Sink.Queue(), Keep.Both) - .Run(_materializer); - - await sourceQueue.OfferAsync(new ConnectTransport(new TcpTransportOptions - { - Host = "127.0.0.1", - Port = 8080 - })); - - var msg = ExpectMsg(TimeSpan.FromSeconds(2), - cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(msg); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpTransportFactorySpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpTransportFactorySpec.cs deleted file mode 100644 index d01b4d0ce..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpTransportFactorySpec.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Akka.Actor; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class TcpTransportFactorySpec -{ - private static readonly IPoolingStrategy TestStrategy = new TestPoolingStrategy(); - - [Fact(Timeout = 5000)] - public void TcpTransportFactory_should_accept_valid_actor_ref() - { - var factory = new TcpTransportFactory(ActorRefs.Nobody, TestStrategy); - - Assert.NotNull(factory); - } - - [Fact(Timeout = 5000)] - public void Create_should_return_non_null_flow() - { - var factory = new TcpTransportFactory(ActorRefs.Nobody, TestStrategy); - - var flow = factory.Create(); - - Assert.NotNull(flow); - } - - [Fact(Timeout = 5000)] - public void Create_should_return_independent_flows() - { - var factory = new TcpTransportFactory(ActorRefs.Nobody, TestStrategy); - - var flow1 = factory.Create(); - var flow2 = factory.Create(); - - Assert.NotSame(flow1, flow2); - } - -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpTransportStateMachineSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpTransportStateMachineSpec.cs deleted file mode 100644 index 4d7f6acc2..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/TcpTransportStateMachineSpec.cs +++ /dev/null @@ -1,886 +0,0 @@ -using System.Buffers; -using Akka.Actor; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -public sealed class TcpTransportStateMachineSpec -{ - private static readonly TcpTransportOptions TestOptions = new() - { - Host = "localhost", - Port = 8080 - }; - - private static readonly IPoolingStrategy TestStrategy = new TestPoolingStrategy(); - - private static (TcpTransportStateMachine Sm, MockTransportOperations Ops) CreateStateMachine() - { - var ops = new MockTransportOperations(); - var sm = new TcpTransportStateMachine( - ops, - ActorRefs.Nobody, - TestStrategy, - ActorRefs.Nobody); - return (sm, ops); - } - - private static ConnectionLease CreateTestLease() - { - var state = new ClientState(Stream.Null); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - return new ConnectionLease(handle, state, cts, ConnectionInfo.None); - } - - private static TransportBuffer CreateTestBuffer(params byte[] data) - { - var buf = TransportBuffer.Rent(data.Length); - data.CopyTo(buf.FullMemory.Span); - buf.Length = data.Length; - return buf; - } - - [Fact(Timeout = 5000)] - public void Dispatch_LeaseAcquired_should_signal_pull_outbound() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - - sm.Dispatch(new LeaseAcquired(lease)); - - Assert.True(ops.PullOutboundCount > 0); - Assert.Contains(ops.CancelledTimers, k => k == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void Dispatch_LeaseAcquired_with_pending_writes_should_flush() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); - - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - Assert.Contains(ops.CancelledTimers, k => k == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundBatch_should_push_inbound_items() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - var items = ArrayPool.Shared.Rent(8); - items[0] = new TransportData(CreateTestBuffer(1, 2, 3)); - items[1] = new TransportData(CreateTestBuffer(4, 5, 6)); - - sm.Dispatch(new InboundBatch(items, 2, 1)); - - Assert.Equal(2, ops.PushedInbound.Count); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundBatch_stale_gen_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - var items = ArrayPool.Shared.Rent(8); - items[0] = new TransportData(CreateTestBuffer(1, 2, 3)); - - sm.Dispatch(new InboundBatch(items, 1, 999)); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_AcquisitionFailed_should_push_disconnected_and_pull() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - ops.PushedInbound.Clear(); - var pullBefore = ops.PullOutboundCount; - - sm.Dispatch(new AcquisitionFailed(new IOException("connection refused"))); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - Assert.True(ops.PullOutboundCount > pullBefore); - } - - [Fact(Timeout = 5000)] - public void Dispatch_AcquisitionFailed_cancelled_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - ops.PushedInbound.Clear(); - var pullBefore = ops.PullOutboundCount; - - sm.Dispatch(new AcquisitionFailed(new OperationCanceledException())); - - Assert.Empty(ops.PushedInbound); - Assert.Equal(pullBefore, ops.PullOutboundCount); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_should_schedule_connect_timeout() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - - Assert.Contains(ops.ScheduledTimers, t => t.Key == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void HandlePush_TransportData_without_handle_should_buffer_and_pull() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - var pullBefore = ops.PullOutboundCount; - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); - - Assert.True(ops.PullOutboundCount > pullBefore); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_without_handle_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandleUpstreamFinish(); - - Assert.Equal(1, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_with_idle_handle_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - sm.HandleUpstreamFinish(); - - Assert.Equal(1, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void OnTimer_connect_timeout_should_push_disconnected() - { - var (sm, ops) = CreateStateMachine(); - sm.HandlePush(new ConnectTransport(TestOptions)); - ops.PushedInbound.Clear(); - - sm.OnTimer("connect-timeout"); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Timeout }); - } - - [Fact(Timeout = 5000)] - public void OnTimer_unknown_key_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - - sm.OnTimer("unknown-timer"); - - Assert.Empty(ops.PushedInbound); - Assert.Equal(0, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_should_push_disconnected() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 1)); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Graceful }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_stale_gen_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 999)); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_with_upstream_finished_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - sm.HandleUpstreamFinish(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 1)); - - Assert.Equal(1, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundPumpFailed_should_push_disconnected() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundPumpFailed(new IOException("pump error"))); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_should_push_disconnected() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new OutboundWriteFailed(new IOException("write failed"))); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - } - - [Fact(Timeout = 5000)] - public void PostStop_should_cancel_connect_timer() - { - var (sm, ops) = CreateStateMachine(); - - sm.PostStop(); - - Assert.Contains(ops.CancelledTimers, k => k == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void HandleDownstreamFinish_should_cleanup_transport() - { - var (sm, _) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - sm.HandleDownstreamFinish(); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public void HandlePush_DisconnectTransport_should_cleanup_and_pull() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - var pullBefore = ops.PullOutboundCount; - - sm.HandlePush(new DisconnectTransport(DisconnectReason.Graceful)); - - Assert.True(ops.PullOutboundCount > pullBefore); - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_with_existing_lease_should_reconnect() - { - var (sm, ops) = CreateStateMachine(); - var lease1 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease1)); - - sm.HandlePush(new ConnectTransport(TestOptions)); - - Assert.False(lease1.IsAlive()); - Assert.Contains(ops.ScheduledTimers, t => t.Key == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_with_tcp_options_should_set_auto_reconnect() - { - var (sm, ops) = CreateStateMachine(); - var options = new TcpTransportOptions { Host = "localhost", Port = 8080, AutoReconnect = true }; - - sm.HandlePush(new ConnectTransport(options)); - - Assert.Contains(ops.ScheduledTimers, t => t.Key == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void HandlePush_TransportData_with_handle_should_write_and_pull() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - var buffer = CreateTestBuffer(7, 8, 9); - var pullBefore = ops.PullOutboundCount; - sm.HandlePush(new TransportData(buffer)); - - Assert.True(ops.PullOutboundCount > pullBefore); - } - - [Fact(Timeout = 5000)] - public void HandlePush_TransportData_multiple_before_connection_should_buffer_all() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - - var buf1 = CreateTestBuffer(1, 2); - var buf2 = CreateTestBuffer(3, 4); - sm.HandlePush(new TransportData(buf1)); - sm.HandlePush(new TransportData(buf2)); - - // Both should be queued - var pullCount = ops.PullOutboundCount; - Assert.True(pullCount >= 3); // connect + 2 data pulls - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_with_pending_writes_should_keep_connection() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); - - sm.HandleUpstreamFinish(); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void HandleDownstreamFinish_with_pending_writes_should_cleanup() - { - var (sm, _) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - var buffer = CreateTestBuffer(5, 6, 7); - sm.HandlePush(new TransportData(buffer)); - - sm.HandleDownstreamFinish(); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public void OnTimer_without_pending_connect_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - - sm.OnTimer("connect-timeout"); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void OnTimer_after_lease_acquired_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - sm.HandlePush(new ConnectTransport(TestOptions)); - - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.OnTimer("connect-timeout"); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void PostStop_with_pending_writes_should_dispose_all() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - var buf1 = CreateTestBuffer(1, 2); - var buf2 = CreateTestBuffer(3, 4); - sm.HandlePush(new TransportData(buf1)); - sm.HandlePush(new TransportData(buf2)); - - sm.PostStop(); - - Assert.Contains(ops.CancelledTimers, k => k == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void PostStop_with_active_lease_should_cleanup() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - sm.PostStop(); - - Assert.False(lease.IsAlive()); - Assert.Contains(ops.CancelledTimers, k => k == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteDone_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - var pullBefore = ops.PullOutboundCount; - - sm.Dispatch(new OutboundWriteDone(1)); - - Assert.Equal(pullBefore, ops.PullOutboundCount); - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_should_signal_pull_when_upstream_not_finished() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - var pullBefore = ops.PullOutboundCount; - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 1)); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Graceful }); - Assert.True(ops.PullOutboundCount > pullBefore); - } - - [Fact(Timeout = 5000)] - public void OnLeaseAcquired_should_increment_connection_generation() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - var lease1 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease1)); - - var items1 = ArrayPool.Shared.Rent(8); - items1[0] = new TransportData(CreateTestBuffer(1, 2, 3)); - sm.Dispatch(new InboundBatch(items1, 1, 1)); - - ops.PushedInbound.Clear(); - - // Now simulate a reconnect by creating a new lease - sm.HandlePush(new ConnectTransport(TestOptions)); - var lease2 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease2)); - - ops.PushedInbound.Clear(); - - // Old generation should be ignored - var items2 = ArrayPool.Shared.Rent(8); - items2[0] = new TransportData(CreateTestBuffer(4, 5, 6)); - sm.Dispatch(new InboundBatch(items2, 1, 1)); // Old generation (1) - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void OnLeaseAcquired_after_reconnect_should_signal_connected() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - var lease1 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease1)); - - ops.PushedInbound.Clear(); - - sm.HandlePush(new ConnectTransport(TestOptions)); // This sets _isReconnecting = true - var lease2 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease2)); - - Assert.Contains(ops.PushedInbound, item => item is TransportConnected); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_error_reason_should_be_preserved() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Error, 1)); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_transient_reason_should_be_preserved() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Transient, 1)); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Transient }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundPumpFailed_should_stop_pumps() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundPumpFailed(new IOException("pump error"))); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_should_return_lease_and_disconnect() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new OutboundWriteFailed(new IOException("write error"))); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - Assert.True(lease.IsAlive()); // Lease not disposed by state machine in Dispatch path - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - var pullBefore = ops.PullOutboundCount; - - sm.Dispatch(new OutboundWriteFailed(new IOException("write error"))); - - Assert.True(ops.PullOutboundCount > pullBefore); - } - - [Fact(Timeout = 5000)] - public void Dispatch_AcquisitionFailed_without_pending_connect_should_be_ignored() - { - var (sm, ops) = CreateStateMachine(); - ops.PushedInbound.Clear(); - - sm.Dispatch(new AcquisitionFailed(new IOException("connection refused"))); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_AcquisitionFailed_should_cancel_timer() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - ops.PushedInbound.Clear(); - ops.CancelledTimers.Clear(); - - sm.Dispatch(new AcquisitionFailed(new IOException("connection refused"))); - - Assert.Contains(ops.CancelledTimers, k => k == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void HandlePush_DisconnectTransport_without_connection_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - var pullBefore = ops.PullOutboundCount; - - sm.HandlePush(new DisconnectTransport(DisconnectReason.Graceful)); - - Assert.True(ops.PullOutboundCount > pullBefore); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_with_default_timeout_should_use_10_seconds() - { - var (sm, ops) = CreateStateMachine(); - var options = new TcpTransportOptions { Host = "localhost", Port = 8080 }; - - sm.HandlePush(new ConnectTransport(options)); - - var timer = ops.ScheduledTimers.First(t => t.Key == "connect-timeout"); - Assert.Equal(TimeSpan.FromSeconds(10), timer.Delay); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_with_custom_timeout_should_use_custom_value() - { - var (sm, ops) = CreateStateMachine(); - var options = new TcpTransportOptions { Host = "localhost", Port = 8080, ConnectTimeout = TimeSpan.FromSeconds(5) }; - - sm.HandlePush(new ConnectTransport(options)); - - var timer = ops.ScheduledTimers.First(t => t.Key == "connect-timeout"); - Assert.Equal(TimeSpan.FromSeconds(5), timer.Delay); - } - - [Fact(Timeout = 5000)] - public void HandlePush_ConnectTransport_with_zero_timeout_should_use_10_seconds() - { - var (sm, ops) = CreateStateMachine(); - var options = new TcpTransportOptions { Host = "localhost", Port = 8080, ConnectTimeout = TimeSpan.Zero }; - - sm.HandlePush(new ConnectTransport(options)); - - var timer = ops.ScheduledTimers.First(t => t.Key == "connect-timeout"); - Assert.Equal(TimeSpan.FromSeconds(10), timer.Delay); - } - - [Fact(Timeout = 5000)] - public void Dispatch_LeaseAcquired_should_start_pump_manager() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - Assert.Contains(ops.ScheduledTimers, t => t.Key == "connect-timeout"); - } - - [Fact(Timeout = 5000)] - public void Dispatch_LeaseAcquired_after_reconnect_should_signal_connected() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - var lease1 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease1)); - - sm.HandlePush(new ConnectTransport(TestOptions)); - ops.PushedInbound.Clear(); - - var lease2 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease2)); - - Assert.Contains(ops.PushedInbound, item => item is TransportConnected); - } - - [Fact(Timeout = 5000)] - public void Dispatch_LeaseAcquired_first_time_should_not_signal_connected() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - ops.PushedInbound.Clear(); - - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - Assert.DoesNotContain(ops.PushedInbound, item => item is TransportConnected); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundBatch_should_return_array_to_pool() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - var items = ArrayPool.Shared.Rent(8); - items[0] = new TransportData(CreateTestBuffer(1, 2, 3)); - - sm.Dispatch(new InboundBatch(items, 1, 1)); - - Assert.Single(ops.PushedInbound); - // Array was returned to pool (impl detail but verifiable by no exceptions) - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundBatch_should_clear_array_items() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - var items = ArrayPool.Shared.Rent(8); - var buffer = CreateTestBuffer(1, 2, 3); - items[0] = new TransportData(buffer); - items[1] = new TransportData(CreateTestBuffer(4, 5, 6)); - - sm.Dispatch(new InboundBatch(items, 2, 1)); - - Assert.Equal(2, ops.PushedInbound.Count); - // Items should be cleared in array (impl detail) - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_with_idle_handle_should_complete_even_after_data_write() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); // Data written, no pending writes left - - sm.HandleUpstreamFinish(); - - Assert.Equal(1, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void Multiple_reconnects_should_increment_generation() - { - var (sm, ops) = CreateStateMachine(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - var lease1 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease1)); - - // Stale generation should be ignored - var items = ArrayPool.Shared.Rent(8); - items[0] = new TransportData(CreateTestBuffer(1, 2, 3)); - sm.Dispatch(new InboundBatch(items, 1, 0)); // Old generation - - ops.PushedInbound.Clear(); - - sm.HandlePush(new ConnectTransport(TestOptions)); - var lease2 = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease2)); - - var items2 = ArrayPool.Shared.Rent(8); - items2[0] = new TransportData(CreateTestBuffer(4, 5, 6)); - sm.Dispatch(new InboundBatch(items2, 1, 2)); // New generation - - Assert.Single(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Pool_strategy_reuse_on_upstream_finish_should_not_dispose_handle() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - sm.HandleUpstreamFinish(); - - Assert.Equal(1, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void Pool_strategy_dispose_on_disconnect_should_notify_manager() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 1)); - - Assert.True(lease.IsAlive()); // Lease still alive in test - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Graceful }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_with_auto_reconnect_should_push_transient_disconnect() - { - var ops = new MockTransportOperations(); - var sm = new TcpTransportStateMachine( - ops, ActorRefs.Nobody, new ReusablePoolingStrategy(), ActorRefs.Nobody); - var options = new TcpTransportOptions { Host = "localhost", Port = 8080, AutoReconnect = true }; - - sm.HandlePush(new ConnectTransport(options)); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 2)); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Transient }); - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_with_auto_reconnect_should_dispose_pending_writes() - { - var ops = new MockTransportOperations(); - var sm = new TcpTransportStateMachine( - ops, ActorRefs.Nobody, new ReusablePoolingStrategy(), ActorRefs.Nobody); - var options = new TcpTransportOptions { Host = "localhost", Port = 8080, AutoReconnect = true }; - - sm.HandlePush(new ConnectTransport(options)); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); - - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 2)); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Transient }); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundPumpFailed_with_upstream_finished_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - - sm.HandleUpstreamFinish(); - ops.CompleteStageCount = 0; - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundPumpFailed(new IOException("pump error"))); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - Assert.Equal(1, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundPumpFailed_without_upstream_finished_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - var lease = CreateTestLease(); - sm.Dispatch(new LeaseAcquired(lease)); - ops.PushedInbound.Clear(); - var pullBefore = ops.PullOutboundCount; - - sm.Dispatch(new InboundPumpFailed(new IOException("pump error"))); - - Assert.Contains(ops.PushedInbound, item => item is TransportDisconnected { Reason: DisconnectReason.Error }); - Assert.True(ops.PullOutboundCount > pullBefore); - Assert.Equal(0, ops.CompleteStageCount); - } - -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Client/TlsClientProviderSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Client/TlsClientProviderSpec.cs deleted file mode 100644 index f40e51087..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Client/TlsClientProviderSpec.cs +++ /dev/null @@ -1,448 +0,0 @@ -using System.Net; -using System.Security.Authentication; -using System.Text; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp.Client; - -[Collection("ClientProvider")] -public sealed class TlsClientProviderSpec -{ - [Fact(Timeout = 5000)] - public void TlsClientProvider_should_initialize_with_options() - { - var options = new TlsTransportOptions - { - Host = "example.com", - Port = 443, - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 - }; - - var provider = new TlsClientProvider(options); - - Assert.NotNull(provider); - } - - [Fact(Timeout = 5000)] - public async Task TlsClientProvider_should_dispose_without_connection() - { - var options = new TlsTransportOptions - { - Host = "example.com", - Port = 443 - }; - - var provider = new TlsClientProvider(options); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task TlsClientProvider_should_handle_double_dispose() - { - var options = new TlsTransportOptions - { - Host = "example.com", - Port = 443 - }; - - var provider = new TlsClientProvider(options); - - await provider.DisposeAsync(); - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_send_correct_request() - { - var targetHost = "example.com"; - var targetPort = 443; - var proxyStream = new MockProxyStream("HTTP/1.1 200 Connection Established\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - targetHost, - targetPort, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - - var requestContent = proxyStream.GetRequestContent(); - Assert.NotNull(requestContent); - Assert.Contains($"CONNECT {targetHost}:{targetPort} HTTP/1.1", requestContent); - Assert.Contains($"Host: {targetHost}:{targetPort}", requestContent); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_succeed_on_200_response() - { - var proxyStream = new MockProxyStream("HTTP/1.1 200 Connection Established\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_succeed_on_HTTP10_200() - { - var proxyStream = new MockProxyStream("HTTP/1.0 200 OK\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_throw_on_407_response() - { - var proxyStream = new MockProxyStream("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); - - var ex = await Assert.ThrowsAsync(() => TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ) - ); - - Assert.Contains("407 Proxy Authentication Required", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_throw_on_non_200() - { - var proxyStream = new MockProxyStream("HTTP/1.1 503 Service Unavailable\r\n\r\n"); - - var ex = await Assert.ThrowsAsync(() => TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ) - ); - - Assert.Contains("503 Service Unavailable", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_include_proxy_auth_header() - { - var credentials = new NetworkCredential("testuser", "testpass"); - var proxyStream = new MockProxyStream("HTTP/1.1 200 Connection Established\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080"), credentials: credentials), - defaultProxyCredentials: null, - CancellationToken.None - ); - - var requestContent = proxyStream.GetRequestContent(); - Assert.NotNull(requestContent); - - var expectedEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes("testuser:testpass")); - Assert.Contains($"Proxy-Authorization: Basic {expectedEncoded}", requestContent); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_not_include_auth_when_no_credentials() - { - var proxyStream = new MockProxyStream("HTTP/1.1 200 Connection Established\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - - var requestContent = proxyStream.GetRequestContent(); - Assert.NotNull(requestContent); - Assert.DoesNotContain("Proxy-Authorization", requestContent); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_use_default_proxy_credentials() - { - var defaultCredentials = new NetworkCredential("defaultuser", "defaultpass"); - var proxyStream = new MockProxyStream("HTTP/1.1 200 Connection Established\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: defaultCredentials, - CancellationToken.None - ); - - var requestContent = proxyStream.GetRequestContent(); - Assert.NotNull(requestContent); - - var expectedEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes("defaultuser:defaultpass")); - Assert.Contains($"Proxy-Authorization: Basic {expectedEncoded}", requestContent); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_prefer_proxy_credentials_over_defaults() - { - var proxyCredentials = new NetworkCredential("proxyuser", "proxypass"); - var defaultCredentials = new NetworkCredential("defaultuser", "defaultpass"); - var proxyStream = new MockProxyStream("HTTP/1.1 200 Connection Established\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080"), credentials: proxyCredentials), - defaultProxyCredentials: defaultCredentials, - CancellationToken.None - ); - - var requestContent = proxyStream.GetRequestContent(); - Assert.NotNull(requestContent); - - var proxyEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes("proxyuser:proxypass")); - var defaultEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes("defaultuser:defaultpass")); - - Assert.Contains($"Proxy-Authorization: Basic {proxyEncoded}", requestContent); - Assert.DoesNotContain($"Proxy-Authorization: Basic {defaultEncoded}", requestContent); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_throw_on_empty_response() - { - var proxyStream = new MockProxyStream(""); - - var ex = await Assert.ThrowsAsync(() => TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ) - ); - - Assert.Contains("Proxy closed connection", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_handle_large_response_buffer() - { - var largeHeaders = string.Concat(Enumerable.Range(0, 10).Select(i => $"X-Custom-Header-{i}: value-{i}\r\n")); - var response = $"HTTP/1.1 200 Connection Established\r\n{largeHeaders}\r\n"; - var proxyStream = new MockProxyStream(response); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_respect_cancellation_token() - { - var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - var proxyStream = new MockProxyStream("HTTP/1.1 200 Connection Established\r\n\r\n"); - - await Assert.ThrowsAsync(() => TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - cts.Token - ) - ); - } - - [Fact(Timeout = 5000)] - public async Task GetStreamAsync_should_throw_on_connection_refused() - { - var options = new TlsTransportOptions - { - Host = "localhost", - Port = (ushort)1, - ConnectTimeout = TimeSpan.FromSeconds(2), - ServerCertificateValidationCallback = (_, _, _, _) => true - }; - - var provider = new TlsClientProvider(options); - - await Assert.ThrowsAnyAsync(() => - provider.GetStreamAsync(TestContext.Current.CancellationToken)); - - await provider.DisposeAsync(); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_throw_on_exceeding_buffer_size() - { - var largeResponse = "HTTP/1.1 200 OK\r\n" + string.Concat(Enumerable.Range(0, 1000).Select(_ => "X-Large-Header: value\r\n")); - var proxyStream = new MockProxyStream(largeResponse); - - var ex = await Assert.ThrowsAsync(() => TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ) - ); - - Assert.Contains("exceeded buffer size", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_handle_chunked_response_reads() - { - var proxyStream = new ChunkedMockProxyStream("HTTP/1.1 200 OK\r\n\r\n", chunkSize: 5); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_work_with_proxy_returning_null() - { - var proxyStream = new MockProxyStream("HTTP/1.1 200 OK\r\n\r\n"); - var bypassedProxy = new TestProxy(null); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - bypassedProxy, - defaultProxyCredentials: null, - CancellationToken.None - ); - - var requestContent = proxyStream.GetRequestContent(); - Assert.NotNull(requestContent); - Assert.Contains("CONNECT", requestContent); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_handle_status_codes_without_reason() - { - var proxyStream = new MockProxyStream("HTTP/1.1 500\r\n\r\n"); - - var ex = await Assert.ThrowsAsync(() => TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ) - ); - - Assert.Contains("500", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_handle_response_with_headers() - { - var response = "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n"; - var proxyStream = new MockProxyStream(response); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_ignore_response_body() - { - var response = "HTTP/1.1 200 OK\r\n\r\nExtra data in response body that should be ignored"; - var proxyStream = new MockProxyStream(response); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_handle_404_response() - { - var proxyStream = new MockProxyStream("HTTP/1.1 404 Not Found\r\n\r\n"); - - var ex = await Assert.ThrowsAsync(() => TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080")), - defaultProxyCredentials: null, - CancellationToken.None - ) - ); - - Assert.Contains("404 Not Found", ex.Message); - } - - [Fact(Timeout = 5000)] - public async Task ConnectTunnel_should_format_credentials_correctly() - { - var credentials = new NetworkCredential("user@domain", "pass:word!"); - var proxyStream = new MockProxyStream("HTTP/1.1 200 OK\r\n\r\n"); - - await TlsClientProvider.EstablishConnectTunnelAsync( - proxyStream, - "example.com", - 443, - new TestProxy(new Uri("http://proxy.local:8080"), credentials: credentials), - defaultProxyCredentials: null, - CancellationToken.None - ); - - var requestContent = proxyStream.GetRequestContent(); - var expectedEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes("user@domain:pass:word!")); - Assert.Contains($"Proxy-Authorization: Basic {expectedEncoded}", requestContent); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/ClientStateSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/ClientStateSpec.cs deleted file mode 100644 index afc703c9f..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/ClientStateSpec.cs +++ /dev/null @@ -1,298 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; - -namespace Servus.Akka.Tests.Transport.Tcp; - -public sealed class ClientStateSpec -{ - [Fact(Timeout = 5000)] - public void ClientState_should_dispose_stream_on_dispose() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - state.Dispose(); - - Assert.Throws(() => stream.ReadByte()); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_create_pipes_by_default() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - Assert.NotNull(state.InboundPipe); - Assert.NotNull(state.OutboundPipe); - } - - [Fact(Timeout = 5000)] - public async Task ClientState_should_have_working_inbound_pipe() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - var writer = state.InboundPipe.Writer; - var data = new byte[] { 1, 2, 3 }; - await writer.WriteAsync(data, TestContext.Current.CancellationToken); - await writer.CompleteAsync(); - - var result = await state.InboundPipe.Reader.ReadAsync(TestContext.Current.CancellationToken); - Assert.Equal(3, result.Buffer.Length); - state.InboundPipe.Reader.AdvanceTo(result.Buffer.End); - await state.InboundPipe.Reader.CompleteAsync(); - } - - [Fact(Timeout = 5000)] - public async Task ClientState_should_have_working_outbound_pipe() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - var writer = state.OutboundPipe.Writer; - var data = new byte[] { 4, 5, 6 }; - await writer.WriteAsync(data, TestContext.Current.CancellationToken); - await writer.CompleteAsync(); - - var result = await state.OutboundPipe.Reader.ReadAsync(TestContext.Current.CancellationToken); - Assert.Equal(3, result.Buffer.Length); - state.OutboundPipe.Reader.AdvanceTo(result.Buffer.End); - await state.OutboundPipe.Reader.CompleteAsync(); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_expose_stream_property() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - Assert.Same(stream, state.Stream); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_allow_on_writes_complete_callback() - { - var stream = new MemoryStream(); - var state = new ClientState(stream) - { - OnWritesComplete = () => { } - }; - - Assert.NotNull(state.OnWritesComplete); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_complete_pipes_on_dispose() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - state.Dispose(); - - Assert.Throws(() => - { - state.InboundPipe.Writer.GetMemory(1); - }); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_handle_double_dispose() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - state.Dispose(); - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_create_with_write_only_direction() - { - var stream = new MemoryStream(); - var state = new ClientState(stream, PipeMode.WriteOnly); - - Assert.Equal(PipeMode.WriteOnly, state.Direction); - Assert.NotNull(state.OutboundPipe); - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_create_with_read_only_direction() - { - var stream = new MemoryStream(); - var state = new ClientState(stream, PipeMode.ReadOnly); - - Assert.Equal(PipeMode.ReadOnly, state.Direction); - Assert.NotNull(state.InboundPipe); - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_default_to_bidirectional_direction() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - Assert.Equal(PipeMode.Bidirectional, state.Direction); - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_expose_on_writes_complete_as_null_by_default() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - Assert.Null(state.OnWritesComplete); - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_expose_channel_readers_and_writers() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - Assert.NotNull(state.InboundReader); - Assert.NotNull(state.InboundWriter); - Assert.NotNull(state.OutboundReader); - Assert.NotNull(state.OutboundWriter); - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_dispose_buffered_channel_items() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - var buf1 = TransportBuffer.Rent(4); - buf1.Length = 4; - var buf2 = TransportBuffer.Rent(4); - buf2.Length = 4; - - state.InboundWriter.TryWrite(buf1); - state.OutboundWriter.TryWrite(buf2); - - state.Dispose(); - - Assert.False(state.InboundReader.TryRead(out _)); - Assert.False(state.OutboundReader.TryRead(out _)); - } - - [Fact(Timeout = 5000)] - public async Task ClientState_should_handle_pre_completed_pipes_on_dispose() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - await state.InboundPipe.Writer.CompleteAsync(); - await state.InboundPipe.Reader.CompleteAsync(); - await state.OutboundPipe.Writer.CompleteAsync(); - await state.OutboundPipe.Reader.CompleteAsync(); - - state.Dispose(); - - Assert.Throws(() => stream.ReadByte()); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_handle_InvalidOperationException_on_writer_complete() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - state.InboundPipe.Writer.Complete(); - state.OutboundPipe.Writer.Complete(); - - // Should not throw - catches InvalidOperationException - state.Dispose(); - - Assert.Throws(() => stream.ReadByte()); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_handle_InvalidOperationException_on_reader_complete() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - state.InboundPipe.Reader.Complete(); - state.OutboundPipe.Reader.Complete(); - - // Should not throw - catches InvalidOperationException - state.Dispose(); - - Assert.Throws(() => stream.ReadByte()); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_drain_multiple_buffered_inbound_items() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - var buf1 = TransportBuffer.Rent(10); - buf1.Length = 10; - var buf2 = TransportBuffer.Rent(10); - buf2.Length = 10; - var buf3 = TransportBuffer.Rent(10); - buf3.Length = 10; - - state.InboundWriter.TryWrite(buf1); - state.InboundWriter.TryWrite(buf2); - state.InboundWriter.TryWrite(buf3); - - state.Dispose(); - - // All buffers should be disposed via drain loop - Assert.False(state.InboundReader.TryRead(out _)); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_drain_multiple_buffered_outbound_items() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - var buf1 = TransportBuffer.Rent(10); - buf1.Length = 10; - var buf2 = TransportBuffer.Rent(10); - buf2.Length = 10; - var buf3 = TransportBuffer.Rent(10); - buf3.Length = 10; - - state.OutboundWriter.TryWrite(buf1); - state.OutboundWriter.TryWrite(buf2); - state.OutboundWriter.TryWrite(buf3); - - state.Dispose(); - - // All buffers should be disposed via drain loop - Assert.False(state.OutboundReader.TryRead(out _)); - } - - [Fact(Timeout = 5000)] - public void ClientState_should_handle_exception_on_all_pipe_completions() - { - var stream = new MemoryStream(); - var state = new ClientState(stream); - - // Complete all pipes first - state.InboundPipe.Writer.Complete(); - state.InboundPipe.Reader.Complete(); - state.OutboundPipe.Writer.Complete(); - state.OutboundPipe.Reader.Complete(); - - // Attempting to complete again should not throw - state.Dispose(); - state.Dispose(); // Double dispose - - Assert.Throws(() => stream.ReadByte()); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/ConnectionHandleSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/ConnectionHandleSpec.cs deleted file mode 100644 index 33ebb936a..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/ConnectionHandleSpec.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Threading.Channels; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; - -namespace Servus.Akka.Tests.Transport.Tcp; - -public sealed class ConnectionHandleSpec -{ - private static (ConnectionHandle Handle, Channel Outbound, Channel Inbound, CancellationTokenSource Cts) CreateHandle() - { - var outbound = Channel.CreateUnbounded(); - var inbound = Channel.CreateUnbounded(); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(outbound.Writer, inbound.Reader, cts.Token); - return (handle, outbound, inbound, cts); - } - - [Fact(Timeout = 5000)] - public void Write_should_send_buffer_to_outbound_channel() - { - var (handle, outbound, _, cts) = CreateHandle(); - var buf = TransportBuffer.Rent(3); - buf.FullMemory.Span[0] = 0xAA; - buf.Length = 1; - - handle.Write(buf); - - Assert.True(outbound.Reader.TryRead(out var received)); - Assert.Equal(0xAA, received.Span[0]); - received.Dispose(); - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void TryRead_should_return_false_when_empty() - { - var (handle, _, _, cts) = CreateHandle(); - - Assert.False(handle.TryRead(out _)); - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void TryRead_should_return_buffer_from_inbound_channel() - { - var (handle, _, inbound, cts) = CreateHandle(); - var buf = TransportBuffer.Rent(3); - buf.FullMemory.Span[0] = 0xBB; - buf.Length = 1; - inbound.Writer.TryWrite(buf); - - Assert.True(handle.TryRead(out var received)); - Assert.Equal(0xBB, received!.Span[0]); - received.Dispose(); - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void SignalClose_should_complete_outbound_writer() - { - var (handle, outbound, _, cts) = CreateHandle(); - - handle.SignalClose(); - - Assert.True(outbound.Reader.Completion.IsCompleted); - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void IsCancelled_should_be_false_initially() - { - var (handle, _, _, cts) = CreateHandle(); - - Assert.False(handle.IsCancelled); - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void IsCancelled_should_be_true_after_token_cancelled() - { - var (handle, _, _, cts) = CreateHandle(); - - cts.Cancel(); - - Assert.True(handle.IsCancelled); - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Write_should_dispose_buffer_when_channel_is_full() - { - var outbound = Channel.CreateBounded(new BoundedChannelOptions(1) - { - FullMode = BoundedChannelFullMode.DropWrite - }); - var inbound = Channel.CreateUnbounded(); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(outbound.Writer, inbound.Reader, cts.Token); - - var buf1 = TransportBuffer.Rent(1); - buf1.Length = 1; - handle.Write(buf1); - - outbound.Writer.TryComplete(); - - var buf2 = TransportBuffer.Rent(1); - buf2.Length = 1; - handle.Write(buf2); - - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Equals_should_return_true_for_same_instance() - { - var (handle, _, _, cts) = CreateHandle(); - - Assert.True(handle.Equals(handle)); - cts.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Equals_should_return_false_for_different_instance() - { - var (handle1, _, _, cts1) = CreateHandle(); - var (handle2, _, _, cts2) = CreateHandle(); - - Assert.NotEqual(handle1, handle2); - cts1.Dispose(); - cts2.Dispose(); - } - - [Fact(Timeout = 5000)] - public void GetHashCode_should_be_consistent() - { - var (handle, _, _, cts) = CreateHandle(); - - var hash1 = handle.GetHashCode(); - var hash2 = handle.GetHashCode(); - - Assert.Equal(hash1, hash2); - cts.Dispose(); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/ConnectionLeaseSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/ConnectionLeaseSpec.cs deleted file mode 100644 index f61afc4ee..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/ConnectionLeaseSpec.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; - -namespace Servus.Akka.Tests.Transport.Tcp; - -public sealed class ConnectionLeaseSpec -{ - private static ConnectionLease CreateLease() - { - var state = new ClientState(Stream.Null); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - var lease = new ConnectionLease(handle, state, cts, ConnectionInfo.None); - return lease; - } - - [Fact(Timeout = 5000)] - public void ConnectionLease_should_set_handle_from_constructor() - { - var lease = CreateLease(); - - Assert.NotNull(lease.Handle); - } - - [Fact(Timeout = 5000)] - public void ConnectionLease_should_be_alive_when_created() - { - var lease = CreateLease(); - - Assert.True(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public void ConnectionLease_should_set_is_alive_false_when_disposed() - { - var lease = CreateLease(); - - lease.Dispose(); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public void ConnectionLease_should_be_safe_when_disposed_twice() - { - var lease = CreateLease(); - - lease.Dispose(); - lease.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ConnectionLease_should_dispose_stream_when_disposed() - { - var memStream = new MemoryStream(); - var state = new ClientState(memStream); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - var lease = new ConnectionLease(handle, state, cts, ConnectionInfo.None); - - lease.Dispose(); - - Assert.Throws(() => memStream.ReadByte()); - } - - [Fact(Timeout = 5000)] - public void IsExpired_should_return_false_for_infinite_lifetime() - { - var lease = CreateLease(); - - Assert.False(lease.IsExpired(Timeout.InfiniteTimeSpan)); - } - - [Fact(Timeout = 5000)] - public void IsExpired_should_return_false_for_recent_connection() - { - var lease = CreateLease(); - - Assert.False(lease.IsExpired(TimeSpan.FromMinutes(1))); - } - - [Fact(Timeout = 5000)] - public async Task IsExpired_should_return_true_for_very_short_lifetime() - { - var lease = CreateLease(); - - await Task.Delay(50, TestContext.Current.CancellationToken); - Assert.True(lease.IsExpired(TimeSpan.FromMilliseconds(1))); - } - - [Fact(Timeout = 5000)] - public void IsExpired_should_treat_minus_one_ms_as_infinite() - { - var lease = CreateLease(); - - Assert.False(lease.IsExpired(TimeSpan.FromMilliseconds(-1))); - } - - [Fact(Timeout = 5000)] - public async Task IsExpired_should_consider_zero_timespan_as_expired_after_tick() - { - var lease = CreateLease(); - - await Task.Delay(2, TestContext.Current.CancellationToken); - Assert.True(lease.IsExpired(TimeSpan.Zero)); - } - - [Fact(Timeout = 5000)] - public void Idempotent_double_dispose_should_not_throw() - { - var lease = CreateLease(); - - lease.Dispose(); - lease.Dispose(); - - Assert.False(lease.IsAlive()); - } - - [Fact(Timeout = 5000)] - public void Handle_should_reflect_cancelled_state_after_dispose() - { - var lease = CreateLease(); - - Assert.False(lease.Handle.IsCancelled); - - lease.Dispose(); - - Assert.True(lease.Handle.IsCancelled); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpListenerFactorySpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpListenerFactorySpec.cs deleted file mode 100644 index e7e051d5b..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpListenerFactorySpec.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Net.Security; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Listener; - -namespace Servus.Akka.Tests.Transport.Tcp.Listener; - -public sealed class TcpListenerFactorySpec -{ - [Fact(Timeout = 5000)] - public void Bind_should_return_non_null_source() - { - var factory = new TcpListenerFactory(); - - var source = factory.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = 0 }); - - Assert.NotNull(source); - } - - [Fact(Timeout = 5000)] - public void Bind_should_throw_for_wrong_options_type() - { - var factory = new TcpListenerFactory(); - - Assert.Throws(() => - factory.Bind(new QuicListenerOptions - { - Host = "127.0.0.1", - Port = 0, - ServerCertificate = null!, - ApplicationProtocols = [SslApplicationProtocol.Http11] - })); - } - - [Fact(Timeout = 5000)] - public void Bind_should_return_independent_sources() - { - var factory = new TcpListenerFactory(); - var options = new TcpListenerOptions { Host = "127.0.0.1", Port = 0 }; - - var source1 = factory.Bind(options); - var source2 = factory.Bind(options); - - Assert.NotSame(source1, source2); - } - - [Fact(Timeout = 5000)] - public void Bind_with_custom_options_should_not_throw() - { - var factory = new TcpListenerFactory(); - var options = new TcpListenerOptions - { - Host = "127.0.0.1", - Port = 0, - ReuseAddress = false, - NoDelay = false, - Backlog = 256, - SocketSendBufferSize = 4096, - SocketReceiveBufferSize = 4096 - }; - - var source = factory.Bind(options); - - Assert.NotNull(source); - } - - [Fact(Timeout = 5000)] - public void TcpListenerFactory_should_implement_IListenerFactory() - { - var factory = new TcpListenerFactory(); - - Assert.IsAssignableFrom(factory); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpServerConnectionStageSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpServerConnectionStageSpec.cs deleted file mode 100644 index 39b766a57..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpServerConnectionStageSpec.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Net; -using Akka.Streams; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Listener; - -namespace Servus.Akka.Tests.Transport.Tcp.Listener; - -public sealed class TcpServerConnectionStageSpec -{ - [Fact(Timeout = 5000)] - public void TcpServerConnectionStage_should_have_flow_shape() - { - var connectionInfo = new ConnectionInfo( - new IPEndPoint(IPAddress.Loopback, 5000), - new IPEndPoint(IPAddress.Loopback, 12345), - TransportProtocol.Tcp); - - var stage = new TcpServerConnectionStage(Stream.Null, connectionInfo); - - Assert.NotNull(stage.Shape); - Assert.IsType>(stage.Shape); - } - - [Fact(Timeout = 5000)] - public void TcpServerConnectionStage_shape_should_have_correct_port_names() - { - var connectionInfo = new ConnectionInfo( - new IPEndPoint(IPAddress.Loopback, 5000), - new IPEndPoint(IPAddress.Loopback, 12345), - TransportProtocol.Tcp); - - var stage = new TcpServerConnectionStage(Stream.Null, connectionInfo); - var shape = stage.Shape; - - Assert.Contains("TcpServerConnection", shape.Inlet.ToString()); - Assert.Contains("TcpServerConnection", shape.Outlet.ToString()); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpServerStateMachineSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpServerStateMachineSpec.cs deleted file mode 100644 index 5fc293712..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/Listener/TcpServerStateMachineSpec.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System.Buffers; -using System.Net; -using Akka.Actor; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Listener; - -namespace Servus.Akka.Tests.Transport.Tcp.Listener; - -public sealed class TcpServerStateMachineSpec -{ - private static readonly ConnectionInfo TestConnectionInfo = new( - new IPEndPoint(IPAddress.Loopback, 5000), - new IPEndPoint(IPAddress.Loopback, 12345), - TransportProtocol.Tcp); - - private static (TcpServerStateMachine Sm, MockTransportOperations Ops) CreateStateMachine(Stream? stream = null) - { - var ops = new MockTransportOperations(); - var state = new ClientState(stream ?? Stream.Null); - var sm = new TcpServerStateMachine(ops, ActorRefs.Nobody, state, TestConnectionInfo); - return (sm, ops); - } - - private static (TcpServerStateMachine Sm, MockTransportOperations Ops) CreateStateMachineWithTls( - bool allowDelayedNegotiation) - { - var ops = new MockTransportOperations(); - var state = new ClientState(Stream.Null); - var sm = new TcpServerStateMachine(ops, ActorRefs.Nobody, state, TestConnectionInfo, - sslStream: null, allowDelayedNegotiation: allowDelayedNegotiation); - return (sm, ops); - } - - private static TransportBuffer CreateTestBuffer(params byte[] data) - { - var buf = TransportBuffer.Rent(data.Length); - data.CopyTo(buf.FullMemory.Span); - buf.Length = data.Length; - return buf; - } - - [Fact(Timeout = 5000)] - public void Start_should_emit_TransportConnected() - { - var (sm, ops) = CreateStateMachine(); - - sm.Start(); - - Assert.Single(ops.PushedInbound); - var connected = Assert.IsType(ops.PushedInbound[0]); - Assert.Equal(TestConnectionInfo, connected.Info); - } - - [Fact(Timeout = 5000)] - public void HandlePush_TransportData_should_signal_pull_outbound() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_DisconnectTransport_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - - sm.HandlePush(new DisconnectTransport(DisconnectReason.Graceful)); - - Assert.True(ops.CompleteStageCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandleUpstreamFinish_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - - sm.HandleUpstreamFinish(); - - Assert.True(ops.CompleteStageCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundBatch_should_push_inbound_items() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - var batch = ArrayPool.Shared.Rent(2); - var buf1 = CreateTestBuffer(1); - var buf2 = CreateTestBuffer(2); - batch[0] = new TransportData(buf1); - batch[1] = new TransportData(buf2); - - sm.Dispatch(new InboundBatch(batch, 2, 1)); - - Assert.Equal(2, ops.PushedInbound.Count); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundBatch_with_stale_gen_should_return_batch_to_pool() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - var batch = ArrayPool.Shared.Rent(1); - batch[0] = new TransportData(CreateTestBuffer(1)); - - sm.Dispatch(new InboundBatch(batch, 1, 999)); - - Assert.Empty(ops.PushedInbound); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_should_push_disconnected() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 1)); - - Assert.Single(ops.PushedInbound); - var disconnected = Assert.IsType(ops.PushedInbound[0]); - Assert.Equal(DisconnectReason.Graceful, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteFailed_should_push_error_disconnected() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.Dispatch(new OutboundWriteFailed(new IOException("test"))); - - Assert.Single(ops.PushedInbound); - var disconnected = Assert.IsType(ops.PushedInbound[0]); - Assert.Equal(DisconnectReason.Error, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public void PostStop_should_not_throw() - { - var (sm, _) = CreateStateMachine(); - sm.Start(); - - sm.PostStop(); - } - - [Fact(Timeout = 5000)] - public void HandlePush_TransportData_before_start_should_dispose_buffer() - { - var (sm, ops) = CreateStateMachine(); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandlePush_TransportData_when_handle_is_null_should_dispose_buffer_and_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - - var buffer = CreateTestBuffer(1, 2, 3); - sm.HandlePush(new TransportData(buffer)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_with_upstream_finished_should_complete_stage() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - var initialCompleteCount = ops.CompleteStageCount; - - sm.HandleUpstreamFinish(); - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 2)); - - Assert.True(ops.CompleteStageCount > initialCompleteCount); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_without_upstream_finished_should_signal_pull() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PullOutboundCount = 0; - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Graceful, 1)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void HandleDownstreamFinish_should_cleanup() - { - var (sm, _) = CreateStateMachine(); - sm.Start(); - - sm.HandleDownstreamFinish(); - - sm.PostStop(); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundPumpFailed_should_push_error_disconnected() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundPumpFailed(new IOException("test error"))); - - Assert.Single(ops.PushedInbound); - var disconnected = Assert.IsType(ops.PushedInbound[0]); - Assert.Equal(DisconnectReason.Error, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public void Start_should_increment_connection_gen() - { - var (sm, ops) = CreateStateMachine(); - - sm.Start(); - - sm.Start(); - - Assert.Equal(2, ops.PushedInbound.Count); - Assert.All(ops.PushedInbound, item => Assert.IsType(item)); - } - - [Fact(Timeout = 5000)] - public void Dispatch_OutboundWriteDone_should_not_push_or_complete() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - var initialCompleteCount = ops.CompleteStageCount; - - sm.Dispatch(new OutboundWriteDone()); - - Assert.Empty(ops.PushedInbound); - Assert.Equal(initialCompleteCount, ops.CompleteStageCount); - } - - [Fact(Timeout = 5000)] - public void HandlePush_unknown_message_type_should_not_throw() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - - sm.HandlePush(new OpenStream(1L, StreamDirection.Bidirectional)); - - Assert.True(ops.PullOutboundCount > 0); - } - - [Fact(Timeout = 5000)] - public void PostStop_before_start_should_not_throw() - { - var (sm, _) = CreateStateMachine(); - - sm.PostStop(); - } - - [Fact(Timeout = 5000)] - public void HandleDownstreamFinish_before_start_should_not_throw() - { - var (sm, _) = CreateStateMachine(); - - sm.HandleDownstreamFinish(); - } - - [Fact(Timeout = 5000)] - public void Dispatch_InboundComplete_error_should_push_error_disconnected() - { - var (sm, ops) = CreateStateMachine(); - sm.Start(); - ops.PushedInbound.Clear(); - - sm.Dispatch(new InboundComplete(DisconnectReason.Error, 1)); - - Assert.Single(ops.PushedInbound); - var disconnected = Assert.IsType(ops.PushedInbound[0]); - Assert.Equal(DisconnectReason.Error, disconnected.Reason); - } - - [Fact(Timeout = 5000)] - public void Start_should_emit_single_TransportConnected_without_tls() - { - var (sm, ops) = CreateStateMachine(); - - sm.Start(); - - Assert.Single(ops.PushedInbound); - Assert.IsType(ops.PushedInbound[0]); - } - - [Fact(Timeout = 5000)] - public void Start_should_include_tls_info_in_TransportConnected_when_allow_delayed() - { - var (sm, ops) = CreateStateMachineWithTls(allowDelayedNegotiation: true); - - sm.Start(); - - Assert.Single(ops.PushedInbound); - var connected = Assert.IsType(ops.PushedInbound[0]); - Assert.NotNull(connected.Info.Security); - Assert.True(connected.Info.Security.AllowDelayedNegotiation); - } - - [Fact(Timeout = 5000)] - public void Start_should_emit_single_TransportConnected_when_no_ssl_and_no_delay() - { - var (sm, ops) = CreateStateMachineWithTls(allowDelayedNegotiation: false); - - sm.Start(); - - Assert.Single(ops.PushedInbound); - Assert.IsType(ops.PushedInbound[0]); - } -} diff --git a/src/Servus.Akka.Tests/Transport/Tcp/TcpPumpManagerSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/TcpPumpManagerSpec.cs deleted file mode 100644 index a4ecce2a3..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/TcpPumpManagerSpec.cs +++ /dev/null @@ -1,128 +0,0 @@ -using Akka.TestKit.Xunit; -using Servus.Akka.Tests.Utils; -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Transport.Tcp; - -public sealed class TcpPumpManagerSpec : TestKit -{ - [Fact(Timeout = 5000)] - public void StartPumps_should_emit_InboundBatch_for_readable_data() - { - var ms = new MemoryStream([0x01, 0x02, 0x03]); - var state = new ClientState(ms); - var manager = new TcpPumpManager(TestActor); - - manager.StartPumps(state, gen: 1); - - var msg = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(1, msg.Gen); - Assert.True(msg.Count > 0); - - for (var i = 0; i < msg.Count; i++) - { - if (msg.Batch[i] is TransportData td) - { - td.Buffer.Dispose(); - } - } - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartPumps_should_emit_InboundComplete_when_stream_ends() - { - var ms = new MemoryStream([]); - var state = new ClientState(ms); - var manager = new TcpPumpManager(TestActor); - - manager.StartPumps(state, gen: 2); - - // Empty stream produces an empty batch before InboundComplete - var batch = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(2, batch.Gen); - - var msg = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(2, msg.Gen); - Assert.Equal(DisconnectReason.Graceful, msg.Reason); - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartPumps_should_emit_InboundPumpFailed_on_stream_error() - { - var ms = new FailingStream(); - var state = new ClientState(ms); - var manager = new TcpPumpManager(TestActor); - - manager.StartPumps(state, gen: 3); - - var msg = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - // Stream error gets wrapped in AbruptCloseException by ClientByteMover.FillPipeFromStream - Assert.IsType(msg.Error); - - state.Dispose(); - } - - [Fact(Timeout = 10000)] - public void StopPumps_should_cancel_inbound_pump() - { - var ms = new SlowStream(); - var state = new ClientState(ms); - var manager = new TcpPumpManager(TestActor); - - manager.StartPumps(state, gen: 4); - - // Give pump a moment to start - Thread.Sleep(50); - - manager.StopPumps(); - - // After StopPumps, the inbound pump is cancelled. - // The outbound pump may send OutboundWriteDone, but no InboundBatch or InboundComplete. - var messages = ReceiveN(1, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - //Assert.Contains(messages, r => r is InboundPumpFailed); - Assert.Contains(messages, r => r is OutboundWriteDone); - - state.Dispose(); - } - - [Fact(Timeout = 5000)] - public void StartPumps_should_batch_multiple_buffers() - { - var bytes = new byte[30]; - for (var i = 0; i < 30; i++) - { - bytes[i] = (byte)i; - } - - var ms = new MemoryStream(bytes); - var state = new ClientState(ms); - var manager = new TcpPumpManager(TestActor); - - manager.StartPumps(state, gen: 5); - - var msg = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(5, msg.Gen); - Assert.True(msg.Count > 0); - - for (var i = 0; i < msg.Count; i++) - { - if (msg.Batch[i] is TransportData td) - { - td.Buffer.Dispose(); - } - } - - state.Dispose(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/Tcp/TcpTransportEventSpec.cs b/src/Servus.Akka.Tests/Transport/Tcp/TcpTransportEventSpec.cs deleted file mode 100644 index aa1822fc8..000000000 --- a/src/Servus.Akka.Tests/Transport/Tcp/TcpTransportEventSpec.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; - -namespace Servus.Akka.Tests.Transport.Tcp; - -public sealed class TcpTransportEventSpec -{ - [Fact(Timeout = 5000)] - public void LeaseAcquired_should_preserve_lease() - { - var state = new ClientState(Stream.Null); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - var lease = new ConnectionLease(handle, state, cts, ConnectionInfo.None); - - var evt = new LeaseAcquired(lease); - - Assert.Same(lease, evt.Lease); - } - - [Fact(Timeout = 5000)] - public void AcquisitionFailed_should_preserve_error() - { - var ex = new IOException("test"); - var evt = new AcquisitionFailed(ex); - - Assert.Same(ex, evt.Error); - } - - [Fact(Timeout = 5000)] - public void InboundBatch_should_preserve_fields() - { - var batch = new ITransportInbound[8]; - var evt = new InboundBatch(batch, 3, 7); - - Assert.Same(batch, evt.Batch); - Assert.Equal(3, evt.Count); - Assert.Equal(7, evt.Gen); - } - - [Fact(Timeout = 5000)] - public void InboundComplete_should_preserve_fields() - { - var evt = new InboundComplete(DisconnectReason.Error, 5); - - Assert.Equal(DisconnectReason.Error, evt.Reason); - Assert.Equal(5, evt.Gen); - } - - [Fact(Timeout = 5000)] - public void InboundPumpFailed_should_preserve_error() - { - var ex = new IOException("pump error"); - var evt = new InboundPumpFailed(ex); - - Assert.Same(ex, evt.Error); - } - - [Fact(Timeout = 5000)] - public void OutboundWriteDone_should_implement_interface() - { - ITcpTransportEvent evt = new OutboundWriteDone(1); - - Assert.IsType(evt); - } - - [Fact(Timeout = 5000)] - public void OutboundWriteFailed_should_preserve_error() - { - var ex = new IOException("write error"); - var evt = new OutboundWriteFailed(ex); - - Assert.Same(ex, evt.Error); - } - - [Fact(Timeout = 5000)] - public void InboundComplete_equality_should_compare_all_fields() - { - var a = new InboundComplete(DisconnectReason.Graceful, 1); - var b = new InboundComplete(DisconnectReason.Graceful, 1); - var c = new InboundComplete(DisconnectReason.Error, 1); - var d = new InboundComplete(DisconnectReason.Graceful, 2); - - Assert.Equal(a, b); - Assert.NotEqual(a, c); - Assert.NotEqual(a, d); - } - - [Fact(Timeout = 5000)] - public void OutboundWriteDone_equality_should_compare_gen() - { - var a = new OutboundWriteDone(1); - var b = new OutboundWriteDone(1); - var c = new OutboundWriteDone(2); - - Assert.Equal(a, b); - Assert.NotEqual(a, c); - } -} diff --git a/src/Servus.Akka.Tests/Transport/TcpListenerOptionsSpec.cs b/src/Servus.Akka.Tests/Transport/TcpListenerOptionsSpec.cs deleted file mode 100644 index badc6281d..000000000 --- a/src/Servus.Akka.Tests/Transport/TcpListenerOptionsSpec.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class TcpListenerOptionsSpec -{ - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_default_client_certificate_mode_to_no_certificate() - { - var options = new TcpListenerOptions { Host = "localhost", Port = 443 }; - - Assert.Equal(ClientCertificateMode.NoCertificate, options.ClientCertificateMode); - } - - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_default_server_certificate_selector_to_null() - { - var options = new TcpListenerOptions { Host = "localhost", Port = 443 }; - - Assert.Null(options.ServerCertificateSelector); - } - - [Fact(Timeout = 5000)] - public void TcpListenerOptions_should_allow_setting_client_certificate_mode() - { - var options = new TcpListenerOptions - { - Host = "localhost", - Port = 443, - ClientCertificateMode = ClientCertificateMode.RequireCertificate - }; - - Assert.Equal(ClientCertificateMode.RequireCertificate, options.ClientCertificateMode); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Transport/TcpPoolConfigSpec.cs b/src/Servus.Akka.Tests/Transport/TcpPoolConfigSpec.cs deleted file mode 100644 index f22cccb4e..000000000 --- a/src/Servus.Akka.Tests/Transport/TcpPoolConfigSpec.cs +++ /dev/null @@ -1,160 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class TcpPoolConfigSpec -{ - [Fact(Timeout = 5000)] - public void Should_store_all_properties() - { - var config = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - Assert.Equal(10, config.MaxConnectionsPerHost); - Assert.Equal(TimeSpan.FromSeconds(30), config.IdleTimeout); - Assert.Equal(TimeSpan.FromMinutes(5), config.ConnectionLifetime); - Assert.True(config.ReuseOnUpstreamFinish); - } - - [Fact(Timeout = 5000)] - public void Default_values_should_be_reasonable() - { - var config = new TcpPoolConfig( - MaxConnectionsPerHost: 5, - IdleTimeout: TimeSpan.FromSeconds(60), - ConnectionLifetime: TimeSpan.FromMinutes(10), - ReuseOnUpstreamFinish: false); - - Assert.True(config.MaxConnectionsPerHost > 0); - Assert.True(config.IdleTimeout > TimeSpan.Zero); - Assert.True(config.ConnectionLifetime > TimeSpan.Zero); - } - - [Fact(Timeout = 5000)] - public void Equality_should_work() - { - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var config2 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - Assert.Equal(config1, config2); - Assert.Equal(config1.GetHashCode(), config2.GetHashCode()); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_max_connections() - { - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var config2 = new TcpPoolConfig( - MaxConnectionsPerHost: 20, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - Assert.NotEqual(config1, config2); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_idle_timeout() - { - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var config2 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(60), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - Assert.NotEqual(config1, config2); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_connection_lifetime() - { - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var config2 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(10), - ReuseOnUpstreamFinish: true); - - Assert.NotEqual(config1, config2); - } - - [Fact(Timeout = 5000)] - public void Inequality_should_work_for_different_reuse_flag() - { - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var config2 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: false); - - Assert.NotEqual(config1, config2); - } - - [Fact(Timeout = 5000)] - public void Should_work_as_dictionary_key() - { - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var config2 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.FromSeconds(30), - ConnectionLifetime: TimeSpan.FromMinutes(5), - ReuseOnUpstreamFinish: true); - - var dict = new Dictionary { { config1, "pooled" } }; - - Assert.True(dict.ContainsKey(config2)); - Assert.Equal("pooled", dict[config2]); - } - - [Fact(Timeout = 5000)] - public void Should_support_zero_or_negative_infinite_timespan_for_lifetime() - { - var config1 = new TcpPoolConfig( - MaxConnectionsPerHost: 10, - IdleTimeout: TimeSpan.Zero, - ConnectionLifetime: Timeout.InfiniteTimeSpan, - ReuseOnUpstreamFinish: false); - - Assert.Equal(TimeSpan.Zero, config1.IdleTimeout); - Assert.Equal(Timeout.InfiniteTimeSpan, config1.ConnectionLifetime); - } -} diff --git a/src/Servus.Akka.Tests/Transport/TransportBufferPoolSpec.cs b/src/Servus.Akka.Tests/Transport/TransportBufferPoolSpec.cs deleted file mode 100644 index 68be90f62..000000000 --- a/src/Servus.Akka.Tests/Transport/TransportBufferPoolSpec.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -[Collection("TransportBuffer")] -public sealed class TransportBufferPoolSpec -{ - [Fact(Timeout = 10000)] - public async Task Pool_should_survive_concurrent_rent_and_dispose() - { - const int threadCount = 8; - const int iterationsPerThread = 500; - - using var barrier = new Barrier(threadCount); - var exceptions = new System.Collections.Concurrent.ConcurrentBag(); - - var tasks = Enumerable.Range(0, threadCount).Select(_ => Task.Run(() => - { - barrier.SignalAndWait(); - for (var i = 0; i < iterationsPerThread; i++) - { - try - { - var buf = TransportBuffer.Rent(64); - buf.Length = 1; - buf.Dispose(); - } - catch (Exception ex) - { - exceptions.Add(ex); - } - } - })).ToArray(); - - await Task.WhenAll(tasks); - - Assert.Empty(exceptions); - } - - [Fact(Timeout = 10000)] - public void Pool_should_not_leak_when_disposed_from_multiple_threads_simultaneously() - { - const int count = 200; - var buffers = new TransportBuffer[count]; - for (var i = 0; i < count; i++) - { - buffers[i] = TransportBuffer.Rent(64); - buffers[i].Length = 1; - } - - Parallel.ForEach(buffers, buf => buf.Dispose()); - - var postBuf = TransportBuffer.Rent(64); - Assert.True(postBuf.Capacity >= 64); - postBuf.Dispose(); - } -} diff --git a/src/Servus.Akka.Tests/Transport/TransportBufferSpec.cs b/src/Servus.Akka.Tests/Transport/TransportBufferSpec.cs deleted file mode 100644 index 43f442cbd..000000000 --- a/src/Servus.Akka.Tests/Transport/TransportBufferSpec.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -[CollectionDefinition("TransportBuffer", DisableParallelization = true)] -public class TransportBufferCollection; - -[Collection("TransportBuffer")] -public sealed class TransportBufferSpec -{ - [Fact(Timeout = 5000)] - public void Rent_should_return_buffer_with_at_least_requested_capacity() - { - var buf = TransportBuffer.Rent(1024); - - Assert.True(buf.Capacity >= 1024); - - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Rent_should_return_buffer_with_zero_length() - { - var buf = TransportBuffer.Rent(256); - - Assert.Equal(0, buf.Length); - - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Memory_should_reflect_length() - { - var buf = TransportBuffer.Rent(256); - buf.Length = 42; - - Assert.Equal(42, buf.Memory.Length); - - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Span_should_reflect_length() - { - var buf = TransportBuffer.Rent(256); - buf.Length = 10; - - Assert.Equal(10, buf.Span.Length); - - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public void FullMemory_should_expose_entire_allocation() - { - var buf = TransportBuffer.Rent(256); - buf.Length = 10; - - Assert.True(buf.FullMemory.Length >= 256); - - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Capacity_should_reflect_total_allocation() - { - var buf = TransportBuffer.Rent(512); - - Assert.True(buf.Capacity >= 512); - Assert.Equal(buf.FullMemory.Length, buf.Capacity); - - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Dispose_should_return_to_pool() - { - var buf = TransportBuffer.Rent(64); - buf.Dispose(); - - var buf2 = TransportBuffer.Rent(64); - - Assert.Same(buf, buf2); - - buf2.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Dispose_should_be_idempotent() - { - var buf = TransportBuffer.Rent(64); - - buf.Dispose(); - buf.Dispose(); - } - - [Fact(Timeout = 5000)] - public void ConfigurePoolSize_should_control_max_pool_size() - { - var original = TransportBuffer.MaxPoolSize; - try - { - TransportBuffer.ConfigurePoolSize(42); - - Assert.Equal(42, TransportBuffer.MaxPoolSize); - } - finally - { - TransportBuffer.ConfigurePoolSize(original); - } - } - - [Fact(Timeout = 5000)] - public void Rent_should_reset_length_on_reused_buffer() - { - var buf = TransportBuffer.Rent(128); - buf.Length = 100; - buf.Dispose(); - - var reused = TransportBuffer.Rent(128); - - Assert.Equal(0, reused.Length); - - reused.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Memory_should_be_writable() - { - var buf = TransportBuffer.Rent(64); - buf.Length = 4; - - buf.Memory.Span[0] = 0xCA; - buf.Memory.Span[1] = 0xFE; - buf.Memory.Span[2] = 0xBA; - buf.Memory.Span[3] = 0xBE; - - Assert.Equal(0xCA, buf.Span[0]); - Assert.Equal(0xFE, buf.Span[1]); - Assert.Equal(0xBA, buf.Span[2]); - Assert.Equal(0xBE, buf.Span[3]); - - buf.Dispose(); - } -} diff --git a/src/Servus.Akka.Tests/Transport/TransportEnumsSpec.cs b/src/Servus.Akka.Tests/Transport/TransportEnumsSpec.cs deleted file mode 100644 index d54ce787b..000000000 --- a/src/Servus.Akka.Tests/Transport/TransportEnumsSpec.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class TransportEnumsSpec -{ - [Fact(Timeout = 5000)] - public void DisconnectReason_should_have_five_values() - { - var values = Enum.GetValues(); - - Assert.Equal(5, values.Length); - } - - [Fact(Timeout = 5000)] - public void DisconnectReason_should_contain_Graceful() - { - Assert.True(Enum.IsDefined(DisconnectReason.Graceful)); - } - - [Fact(Timeout = 5000)] - public void DisconnectReason_should_contain_Timeout() - { - Assert.True(Enum.IsDefined(DisconnectReason.Timeout)); - } - - [Fact(Timeout = 5000)] - public void DisconnectReason_should_contain_Error() - { - Assert.True(Enum.IsDefined(DisconnectReason.Error)); - } - - [Fact(Timeout = 5000)] - public void DisconnectReason_should_contain_Evicted() - { - Assert.True(Enum.IsDefined(DisconnectReason.Evicted)); - } - - [Fact(Timeout = 5000)] - public void PoolAction_should_have_two_values() - { - var values = Enum.GetValues(); - - Assert.Equal(2, values.Length); - } - - [Fact(Timeout = 5000)] - public void PoolAction_should_contain_Reuse() - { - Assert.True(Enum.IsDefined(PoolAction.Reuse)); - } - - [Fact(Timeout = 5000)] - public void PoolAction_should_contain_Dispose() - { - Assert.True(Enum.IsDefined(PoolAction.Dispose)); - } -} diff --git a/src/Servus.Akka.Tests/Transport/TransportMessagesSpec.cs b/src/Servus.Akka.Tests/Transport/TransportMessagesSpec.cs deleted file mode 100644 index c27c3c250..000000000 --- a/src/Servus.Akka.Tests/Transport/TransportMessagesSpec.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Net; -using System.Net.Security; -using System.Security.Authentication; -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class TransportMessagesSpec -{ - private static readonly ConnectionInfo TestConnectionInfo = new( - Local: new IPEndPoint(IPAddress.Loopback, 12345), - Remote: new IPEndPoint(IPAddress.Parse("93.184.216.34"), 443), - Protocol: TransportProtocol.Tls, - Security: new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2)); - - [Fact(Timeout = 5000)] - public void ConnectTransport_should_implement_ITransportOutbound() - { - ITransportOutbound msg = new ConnectTransport(new TcpTransportOptions - { - Host = "localhost", - Port = 80 - }); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void ConnectTransport_should_carry_options() - { - var opts = new TlsTransportOptions { Host = "example.com", Port = 443 }; - var msg = new ConnectTransport(opts); - - Assert.Same(opts, msg.Options); - } - - [Fact(Timeout = 5000)] - public void DisconnectTransport_should_implement_ITransportOutbound() - { - ITransportOutbound msg = new DisconnectTransport(DisconnectReason.Graceful); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void DisconnectTransport_should_carry_reason() - { - var msg = new DisconnectTransport(DisconnectReason.Timeout); - - Assert.Equal(DisconnectReason.Timeout, msg.Reason); - } - - [Fact(Timeout = 5000)] - public void TransportConnected_should_implement_ITransportInbound() - { - ITransportInbound msg = new TransportConnected(TestConnectionInfo); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void TransportConnected_should_carry_connection_info() - { - var msg = new TransportConnected(TestConnectionInfo); - - Assert.Equal(TestConnectionInfo, msg.Info); - } - - [Fact(Timeout = 5000)] - public void TransportDisconnected_should_implement_ITransportInbound() - { - ITransportInbound msg = new TransportDisconnected(DisconnectReason.Error); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void TransportDisconnected_should_carry_reason() - { - var msg = new TransportDisconnected(DisconnectReason.Evicted); - - Assert.Equal(DisconnectReason.Evicted, msg.Reason); - } - - [Fact(Timeout = 5000)] - public void TransportError_should_implement_ITransportInbound() - { - ITransportInbound msg = new TransportError(new InvalidOperationException("test"), Fatal: true); - - Assert.IsType(msg); - } - - [Fact(Timeout = 5000)] - public void TransportError_should_carry_exception_and_fatal_flag() - { - var ex = new TimeoutException("timed out"); - var msg = new TransportError(ex, Fatal: false); - - Assert.Same(ex, msg.Exception); - Assert.False(msg.Fatal); - } - - [Fact(Timeout = 5000)] - public void ConnectionInfo_should_expose_all_fields() - { - var local = new IPEndPoint(IPAddress.Loopback, 5000); - var remote = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 443); - var security = new SecurityInfo(SslProtocols.Tls12, SslApplicationProtocol.Http11); - - var info = new ConnectionInfo(local, remote, TransportProtocol.Tls, security); - - Assert.Equal(local, info.Local); - Assert.Equal(remote, info.Remote); - Assert.Equal(TransportProtocol.Tls, info.Protocol); - Assert.NotNull(info.Security); - Assert.Equal(SslProtocols.Tls12, info.Security.Protocol); - Assert.Equal(SslApplicationProtocol.Http11, info.Security.ApplicationProtocol); - } - - [Fact(Timeout = 5000)] - public void ConnectionInfo_should_allow_null_security() - { - var info = new ConnectionInfo( - new IPEndPoint(IPAddress.Loopback, 5000), - new IPEndPoint(IPAddress.Loopback, 80), - TransportProtocol.Tcp); - - Assert.Null(info.Security); - } - - [Fact(Timeout = 5000)] - public void SecurityInfo_should_default_negotiated_cipher_suite_to_null() - { - var info = new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2); - - Assert.Null(info.NegotiatedCipherSuite); - } - - [Fact(Timeout = 5000)] - public void SecurityInfo_should_default_hostname_to_null() - { - var info = new SecurityInfo(SslProtocols.Tls13, SslApplicationProtocol.Http2); - - Assert.Null(info.HostName); - } - - [Fact(Timeout = 5000)] - public void SecurityInfo_should_store_negotiated_cipher_suite() - { - var info = new SecurityInfo( - SslProtocols.Tls13, - SslApplicationProtocol.Http2, - TlsCipherSuite.TLS_AES_256_GCM_SHA384); - - Assert.Equal(TlsCipherSuite.TLS_AES_256_GCM_SHA384, info.NegotiatedCipherSuite); - } - - [Fact(Timeout = 5000)] - public void SecurityInfo_should_store_hostname() - { - var info = new SecurityInfo( - SslProtocols.Tls13, - SslApplicationProtocol.Http2, - HostName: "example.com"); - - Assert.Equal("example.com", info.HostName); - } - - [Fact(Timeout = 5000)] - public void SecurityInfo_should_store_all_fields() - { - var info = new SecurityInfo( - SslProtocols.Tls13, - SslApplicationProtocol.Http2, - TlsCipherSuite.TLS_AES_128_GCM_SHA256, - "host.example.com"); - - Assert.Equal(SslProtocols.Tls13, info.Protocol); - Assert.Equal(SslApplicationProtocol.Http2, info.ApplicationProtocol); - Assert.Equal(TlsCipherSuite.TLS_AES_128_GCM_SHA256, info.NegotiatedCipherSuite); - Assert.Equal("host.example.com", info.HostName); - } - - [Fact(Timeout = 5000)] - public void SecurityInfo_should_carry_ssl_stream_and_delayed_negotiation() - { - var info = new SecurityInfo( - SslProtocols.Tls13, - SslApplicationProtocol.Http2, - AllowDelayedNegotiation: true); - - Assert.Null(info.SslStream); - Assert.True(info.AllowDelayedNegotiation); - } -} diff --git a/src/Servus.Akka.Tests/Transport/TransportOptionsSpec.cs b/src/Servus.Akka.Tests/Transport/TransportOptionsSpec.cs deleted file mode 100644 index cdf442f37..000000000 --- a/src/Servus.Akka.Tests/Transport/TransportOptionsSpec.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System.Net; -using System.Net.Security; -using System.Security.Authentication; -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Transport; - -public sealed class TransportOptionsSpec -{ - [Fact(Timeout = 5000)] - public void TcpTransportOptions_should_have_default_connect_timeout() - { - var opts = new TcpTransportOptions - { - Host = "localhost", - Port = 80 - }; - - Assert.Equal(TimeSpan.FromSeconds(10), opts.ConnectTimeout); - } - - [Fact(Timeout = 5000)] - public void TcpTransportOptions_should_be_assignable_to_TransportOptions() - { - TransportOptions opts = new TcpTransportOptions - { - Host = "localhost", - Port = 80 - }; - - Assert.IsType(opts); - } - - [Fact(Timeout = 5000)] - public void TlsTransportOptions_should_be_assignable_to_TransportOptions() - { - TransportOptions opts = new TlsTransportOptions - { - Host = "localhost", - Port = 443 - }; - - Assert.IsType(opts); - } - - [Fact(Timeout = 5000)] - public void QuicTransportOptions_should_be_assignable_to_TransportOptions() - { - TransportOptions opts = new QuicTransportOptions - { - Host = "localhost", - Port = 443 - }; - - Assert.IsType(opts); - } - - [Fact(Timeout = 5000)] - public void TcpTransportOptions_should_expose_proxy_settings() - { - var proxy = new WebProxy("http://proxy:8080"); - var opts = new TcpTransportOptions - { - Host = "localhost", - Port = 80, - UseProxy = true, - Proxy = proxy, - DefaultProxyCredentials = CredentialCache.DefaultCredentials - }; - - Assert.True(opts.UseProxy); - Assert.Same(proxy, opts.Proxy); - Assert.Same(CredentialCache.DefaultCredentials, opts.DefaultProxyCredentials); - } - - [Fact(Timeout = 5000)] - public void TlsTransportOptions_should_expose_tls_settings() - { - var opts = new TlsTransportOptions - { - Host = "example.com", - Port = 443, - TargetHost = "example.com", - EnabledSslProtocols = SslProtocols.Tls13, - ApplicationProtocols = [SslApplicationProtocol.Http2] - }; - - Assert.Equal("example.com", opts.TargetHost); - Assert.Equal(SslProtocols.Tls13, opts.EnabledSslProtocols); - Assert.Single(opts.ApplicationProtocols); - } - - [Fact(Timeout = 5000)] - public void TlsTransportOptions_should_default_ssl_protocols_to_none() - { - var opts = new TlsTransportOptions - { - Host = "example.com", - Port = 443 - }; - - Assert.Equal(SslProtocols.None, opts.EnabledSslProtocols); - } - - [Fact(Timeout = 5000)] - public void QuicTransportOptions_should_have_correct_defaults() - { - var opts = new QuicTransportOptions - { - Host = "example.com", - Port = 443 - }; - - Assert.Equal(TimeSpan.FromSeconds(30), opts.IdleTimeout); - Assert.Equal(100, opts.MaxBidirectionalStreams); - Assert.Equal(3, opts.MaxUnidirectionalStreams); - Assert.True(opts.AllowConnectionMigration); - } - - [Fact(Timeout = 5000)] - public void Equality_should_be_case_insensitive_for_host() - { - var a = new TcpTransportOptions { Host = "EXAMPLE.COM", Port = 80 }; - var b = new TcpTransportOptions { Host = "example.com", Port = 80 }; - - Assert.Equal(a, b); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - } - - [Fact(Timeout = 5000)] - public void Equality_should_differ_for_different_ports() - { - var a = new TcpTransportOptions { Host = "example.com", Port = 80 }; - var b = new TcpTransportOptions { Host = "example.com", Port = 8080 }; - - Assert.NotEqual(a, b); - } - - [Fact(Timeout = 5000)] - public void Equality_should_differ_across_transport_types() - { - var tcp = new TcpTransportOptions { Host = "example.com", Port = 443 }; - var tls = new TlsTransportOptions { Host = "example.com", Port = 443 }; - - Assert.False(tcp.Equals(tls)); - } - - [Fact(Timeout = 5000)] - public void Equality_should_match_identical_tcp_options() - { - var a = new TcpTransportOptions { Host = "example.com", Port = 80 }; - var b = new TcpTransportOptions { Host = "example.com", Port = 80 }; - - Assert.Equal(a, b); - Assert.True(a == b); - } - - [Fact(Timeout = 5000)] - public void Equality_should_match_identical_quic_options() - { - var a = new QuicTransportOptions { Host = "example.com", Port = 443 }; - var b = new QuicTransportOptions { Host = "example.com", Port = 443 }; - - Assert.Equal(a, b); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - } - - [Fact(Timeout = 5000)] - public void GetHashCode_should_be_case_insensitive_for_host() - { - var a = new TlsTransportOptions { Host = "EXAMPLE.COM", Port = 443 }; - var b = new TlsTransportOptions { Host = "example.com", Port = 443 }; - - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - } - - [Fact(Timeout = 5000)] - public void TransportOptions_should_work_as_dictionary_key() - { - var dict = new Dictionary(); - var key = new TcpTransportOptions { Host = "example.com", Port = 80 }; - var sameCaseDifferent = new TcpTransportOptions { Host = "EXAMPLE.COM", Port = 80 }; - - dict[key] = "pooled"; - - Assert.True(dict.ContainsKey(sameCaseDifferent)); - Assert.Equal("pooled", dict[sameCaseDifferent]); - } - - [Fact(Timeout = 5000)] - public void SocketBufferSizes_should_default_to_null() - { - var opts = new TcpTransportOptions { Host = "localhost", Port = 80 }; - - Assert.Null(opts.SocketSendBufferSize); - Assert.Null(opts.SocketReceiveBufferSize); - } - - [Fact(Timeout = 5000)] - public void SocketBufferSizes_should_be_settable() - { - var opts = new TcpTransportOptions - { - Host = "localhost", - Port = 80, - SocketSendBufferSize = 65536, - SocketReceiveBufferSize = 131072 - }; - - Assert.Equal(65536, opts.SocketSendBufferSize); - Assert.Equal(131072, opts.SocketReceiveBufferSize); - } - - [Fact(Timeout = 5000)] - public void Equals_should_return_false_for_null() - { - var opts = new TcpTransportOptions { Host = "localhost", Port = 80 }; - - Assert.False(opts.Equals(null)); - Assert.False(opts == null); - Assert.True(opts != null); - } - - [Fact(Timeout = 5000)] - public void Equals_should_return_true_for_same_reference() - { - var opts = new TcpTransportOptions { Host = "localhost", Port = 80 }; - - Assert.True(opts.Equals(opts)); - Assert.True(ReferenceEquals(opts, opts)); - } - - [Fact(Timeout = 5000)] - public void Equals_should_return_true_for_same_values_different_instances() - { - var a = new TcpTransportOptions { Host = "example.com", Port = 8080 }; - var b = new TcpTransportOptions { Host = "example.com", Port = 8080 }; - - Assert.True(a.Equals(b)); - Assert.False(ReferenceEquals(a, b)); - } - - [Fact(Timeout = 5000)] - public void Equals_should_return_false_for_different_host() - { - var a = new TcpTransportOptions { Host = "example.com", Port = 80 }; - var b = new TcpTransportOptions { Host = "different.com", Port = 80 }; - - Assert.False(a.Equals(b)); - Assert.False(a == b); - } - - [Fact(Timeout = 5000)] - public void Equals_should_handle_tls_options_with_same_host_port() - { - var a = new TlsTransportOptions { Host = "example.com", Port = 443 }; - var b = new TlsTransportOptions { Host = "example.com", Port = 443 }; - - Assert.True(a.Equals(b)); - Assert.True(a == b); - } - - [Fact(Timeout = 5000)] - public void Equals_should_handle_quic_options_null_check() - { - var opts = new QuicTransportOptions { Host = "example.com", Port = 443 }; - - Assert.NotNull(opts); - Assert.False(opts.Equals(null)); - } -} diff --git a/src/Servus.Akka.Tests/Utils/CapturingStream.cs b/src/Servus.Akka.Tests/Utils/CapturingStream.cs deleted file mode 100644 index cb42d02bf..000000000 --- a/src/Servus.Akka.Tests/Utils/CapturingStream.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Servus.Akka.Tests.Utils; - -public sealed class CapturingStream(List writes) : Stream -{ - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) - { - writes.Add(buffer.ToArray()); - await Task.CompletedTask; - } - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override void Flush() - { - } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/ChunkedMockProxyStream.cs b/src/Servus.Akka.Tests/Utils/ChunkedMockProxyStream.cs deleted file mode 100644 index 43c42ce72..000000000 --- a/src/Servus.Akka.Tests/Utils/ChunkedMockProxyStream.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Text; - -namespace Servus.Akka.Tests.Utils; - -/// -/// Mock proxy stream that simulates chunked reading behavior for testing multi-read scenarios. -/// -public sealed class ChunkedMockProxyStream : Stream -{ - private readonly byte[] _responseBytes; - private readonly MemoryStream _writeBuffer = new(); - private int _readPosition; - private bool _responseWritten; - private readonly int _chunkSize; - - public ChunkedMockProxyStream(string response, int chunkSize = 1) - { - if (chunkSize <= 0) - { - throw new ArgumentException("Chunk size must be greater than 0", nameof(chunkSize)); - } - - _responseBytes = Encoding.ASCII.GetBytes(response); - _chunkSize = chunkSize; - } - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() - { - } - - public override async Task FlushAsync(CancellationToken cancellationToken) - { - _responseWritten = true; - _readPosition = 0; - await Task.CompletedTask; - } - - public override int Read(byte[] buffer, int offset, int count) - { - throw new NotSupportedException("Use ReadAsync instead"); - } - - public override async ValueTask ReadAsync(Memory buffer, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!_responseWritten) - { - await Task.Yield(); - return 0; - } - - if (_readPosition >= _responseBytes.Length) - { - return 0; - } - - // Read in chunks to simulate network behavior - var bytesToRead = Math.Min(_chunkSize, Math.Min(buffer.Length, _responseBytes.Length - _readPosition)); - _responseBytes.AsMemory(_readPosition, bytesToRead).CopyTo(buffer); - _readPosition += bytesToRead; - - return bytesToRead; - } - - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotSupportedException("Use WriteAsync instead"); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - await _writeBuffer.WriteAsync(buffer, cancellationToken); - await Task.CompletedTask; - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } - - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - public string GetRequestContent() - { - return Encoding.ASCII.GetString(_writeBuffer.ToArray()); - } -} diff --git a/src/Servus.Akka.Tests/Utils/DuplexPipeStream.cs b/src/Servus.Akka.Tests/Utils/DuplexPipeStream.cs deleted file mode 100644 index fdaabd9cb..000000000 --- a/src/Servus.Akka.Tests/Utils/DuplexPipeStream.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.IO.Pipelines; - -namespace Servus.Akka.Tests.Utils; - -public sealed class DuplexPipeStream(PipeReader reader, PipeWriter writer) : Stream -{ - private bool _disposed; - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { - if (_disposed) - { - return 0; - } - - var result = await reader.ReadAsync(ct); - var sequence = result.Buffer; - - if (sequence.IsEmpty && result.IsCompleted) - { - return 0; - } - - var bytesToCopy = (int)Math.Min(buffer.Length, sequence.Length); - var sliced = sequence.Slice(0, bytesToCopy); - foreach (var segment in sliced) - { - segment.Span.CopyTo(buffer.Span); - buffer = buffer[(int)segment.Length..]; - } - - reader.AdvanceTo(sliced.End); - return bytesToCopy; - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) - { - await writer.WriteAsync(buffer, ct); - } - - public override async Task FlushAsync(CancellationToken ct) - { - await writer.FlushAsync(ct); - } - - protected override void Dispose(bool disposing) - { - if (!_disposed) - { - _disposed = true; - writer.Complete(); - reader.Complete(); - } - - base.Dispose(disposing); - } - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override void Flush() - { - } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/FailOnceTcpConnectionFactory.cs b/src/Servus.Akka.Tests/Utils/FailOnceTcpConnectionFactory.cs deleted file mode 100644 index 2cf8e92e0..000000000 --- a/src/Servus.Akka.Tests/Utils/FailOnceTcpConnectionFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Utils; - -internal sealed class FailOnceTcpConnectionFactory : ITcpConnectionFactory -{ - private int _callCount; - - public Task EstablishAsync(TransportOptions options, CancellationToken ct) - { - ct.ThrowIfCancellationRequested(); - - if (Interlocked.Increment(ref _callCount) == 1) - { - return Task.FromException(new IOException("Simulated first-call connection failure")); - } - - var state = new ClientState(Stream.Null); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - return Task.FromResult(new ConnectionLease(handle, state, cts, ConnectionInfo.None)); - } -} diff --git a/src/Servus.Akka.Tests/Utils/FailingStream.cs b/src/Servus.Akka.Tests/Utils/FailingStream.cs deleted file mode 100644 index 929669a08..000000000 --- a/src/Servus.Akka.Tests/Utils/FailingStream.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Servus.Akka.Tests.Utils; - -public sealed class FailingStream : Stream -{ - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { - throw new IOException("Test stream failure"); - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) - { - throw new IOException("Test stream failure"); - } - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override void Flush() - { - } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/FakeReentrantProvider.cs b/src/Servus.Akka.Tests/Utils/FakeReentrantProvider.cs deleted file mode 100644 index 464d932fb..000000000 --- a/src/Servus.Akka.Tests/Utils/FakeReentrantProvider.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Tests.Utils; - -public sealed class FakeReentrantProvider : IClientProvider -{ - private readonly TimeSpan _connectDelay; - private readonly bool _failStreamOpen; - private readonly SemaphoreSlim _connectLock = new(1, 1); - private object? _connection; // simulates QuicConnection - private int _connectionCount; - private int _streamCount; - - public FakeReentrantProvider(int streamCount, TimeSpan connectDelay = default, bool failStreamOpen = false) - { - _ = streamCount; // reserved for future stream-limit tests - _connectDelay = connectDelay; - _failStreamOpen = failStreamOpen; - } - - public EndPoint? RemoteEndPoint => _connection is not null ? new IPEndPoint(IPAddress.Loopback, 443) : null; - public bool SupportsMultipleStreams => true; - public int ConnectionCount => _connectionCount; - public int StreamCount => _streamCount; - - public async Task GetStreamAsync(CancellationToken ct = default) - { - await EnsureConnectedAsync(ct).ConfigureAwait(false); - - if (_failStreamOpen) - { - Interlocked.Exchange(ref _connection, null); - throw new InvalidOperationException( - "QUIC connection to 'fake:443' is no longer usable. " - + "A new connection will be established on the next request."); - } - - Interlocked.Increment(ref _streamCount); - return new MemoryStream(); - } - - public void KillConnection() - { - Interlocked.Exchange(ref _connection, null); - } - - public void Close() - { - Interlocked.Exchange(ref _connection, null); - } - - public ValueTask DisposeAsync() - { - Close(); - return ValueTask.CompletedTask; - } - - private async Task EnsureConnectedAsync(CancellationToken ct) - { - if (Volatile.Read(ref _connection) is not null) - { - return; - } - - await _connectLock.WaitAsync(ct).ConfigureAwait(false); - try - { - if (Volatile.Read(ref _connection) is not null) - { - return; - } - - if (_connectDelay > TimeSpan.Zero) - { - await Task.Delay(_connectDelay, ct).ConfigureAwait(false); - } - - Volatile.Write(ref _connection, new object()); - Interlocked.Increment(ref _connectionCount); - } - finally - { - _connectLock.Release(); - } - } -} - -public sealed class MinimalClientProvider : IClientProvider -{ - public EndPoint? RemoteEndPoint => null; - - public Task GetStreamAsync(CancellationToken ct = default) => - Task.FromResult(new MemoryStream()); - - public static void Close() - { - } - - public ValueTask DisposeAsync() - { - Close(); - return ValueTask.CompletedTask; - } -} - -public interface IClientProvider : IAsyncDisposable -{ - EndPoint? RemoteEndPoint { get; } - bool SupportsMultipleStreams => false; - Task GetStreamAsync(CancellationToken ct = default); -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/InMemoryTcpConnectionFactory.cs b/src/Servus.Akka.Tests/Utils/InMemoryTcpConnectionFactory.cs deleted file mode 100644 index 16415dd59..000000000 --- a/src/Servus.Akka.Tests/Utils/InMemoryTcpConnectionFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Utils; - -internal sealed class InMemoryTcpConnectionFactory : ITcpConnectionFactory -{ - private readonly List _established = []; - - public IReadOnlyList EstablishedLeases => _established; - - public Task EstablishAsync(TransportOptions options, CancellationToken ct) - { - ct.ThrowIfCancellationRequested(); - - var state = new ClientState(Stream.Null); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - var lease = new ConnectionLease(handle, state, cts, ConnectionInfo.None); - - _established.Add(lease); - return Task.FromResult(lease); - } -} diff --git a/src/Servus.Akka.Tests/Utils/LoopbackQuicServer.cs b/src/Servus.Akka.Tests/Utils/LoopbackQuicServer.cs deleted file mode 100644 index 8591916de..000000000 --- a/src/Servus.Akka.Tests/Utils/LoopbackQuicServer.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Net; -using System.Net.Quic; -using System.Net.Security; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; - -namespace Servus.Akka.Tests.Utils; - -public sealed class LoopbackQuicServer : IAsyncDisposable -{ - public static SslApplicationProtocol Alpn => new("h3"); - private readonly QuicListener _listener; - private readonly X509Certificate2 _cert; - public int Port { get; } - - private LoopbackQuicServer(QuicListener listener, X509Certificate2 cert, int port) - { - _listener = listener; - _cert = cert; - Port = port; - } - - public static async Task CreateAsync() - { - using var rsa = RSA.Create(2048); - var req = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var san = new SubjectAlternativeNameBuilder(); - san.AddDnsName("localhost"); - san.AddIpAddress(IPAddress.Loopback); - req.CertificateExtensions.Add(san.Build()); - req.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); - var ephemeral = req.CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddHours(1)); - var pfx = ephemeral.Export(X509ContentType.Pfx, ""); - var cert = X509CertificateLoader.LoadPkcs12(pfx, "", X509KeyStorageFlags.Exportable); - ephemeral.Dispose(); - - var certContext = SslStreamCertificateContext.Create(cert, null); - var protocols = new List { Alpn }; - - var listener = await QuicListener.ListenAsync(new QuicListenerOptions - { - ListenEndPoint = new IPEndPoint(IPAddress.IPv6Loopback, 0), - ApplicationProtocols = protocols, - ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(new QuicServerConnectionOptions - { - DefaultStreamErrorCode = 0x0100, - DefaultCloseErrorCode = 0x0100, - ServerAuthenticationOptions = new SslServerAuthenticationOptions - { - ServerCertificateContext = certContext, - ApplicationProtocols = protocols - } - }) - }); - - var port = listener.LocalEndPoint.Port; - return new LoopbackQuicServer(listener, cert, port); - } - - public async Task AcceptConnectionAsync(CancellationToken ct = default) - { - return await _listener.AcceptConnectionAsync(ct); - } - - public async ValueTask DisposeAsync() - { - await _listener.DisposeAsync(); - _cert.Dispose(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/MockProxyStream.cs b/src/Servus.Akka.Tests/Utils/MockProxyStream.cs deleted file mode 100644 index d6198bfdf..000000000 --- a/src/Servus.Akka.Tests/Utils/MockProxyStream.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Text; - -namespace Servus.Akka.Tests.Utils; - -public sealed class MockProxyStream : Stream -{ - private readonly byte[] _responseBytes; - private readonly MemoryStream _writeBuffer = new(); - private int _readPosition; - private bool _responseWritten; - - public MockProxyStream(string response) - { - _responseBytes = Encoding.ASCII.GetBytes(response); - } - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() - { - } - - public override async Task FlushAsync(CancellationToken cancellationToken) - { - _responseWritten = true; - _readPosition = 0; - await Task.CompletedTask; - } - - public override int Read(byte[] buffer, int offset, int count) - { - throw new NotSupportedException("Use ReadAsync instead"); - } - - public override async ValueTask ReadAsync(Memory buffer, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!_responseWritten) - { - await Task.Yield(); - return 0; - } - - if (_readPosition >= _responseBytes.Length) - { - return 0; - } - - var bytesToRead = Math.Min(buffer.Length, _responseBytes.Length - _readPosition); - _responseBytes.AsMemory(_readPosition, bytesToRead).CopyTo(buffer); - _readPosition += bytesToRead; - - return bytesToRead; - } - - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotSupportedException("Use WriteAsync instead"); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - await _writeBuffer.WriteAsync(buffer, cancellationToken); - await Task.CompletedTask; - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } - - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - public string GetRequestContent() - { - return Encoding.ASCII.GetString(_writeBuffer.ToArray()); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/MockTransportOperations.cs b/src/Servus.Akka.Tests/Utils/MockTransportOperations.cs deleted file mode 100644 index 04d8d856d..000000000 --- a/src/Servus.Akka.Tests/Utils/MockTransportOperations.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Akka.Event; -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Utils; - -internal sealed class MockTransportOperations : ITransportOperations -{ - public List PushedInbound { get; } = []; - public int PullOutboundCount { get; set; } - public int CompleteStageCount { get; set; } - public List<(string Key, TimeSpan Delay)> ScheduledTimers { get; } = []; - public List CancelledTimers { get; } = []; - - public void OnPushInbound(ITransportInbound item) => PushedInbound.Add(item); - public void OnSignalPullOutbound() => PullOutboundCount++; - public void OnCompleteStage() => CompleteStageCount++; - public void OnScheduleTimer(string key, TimeSpan delay) => ScheduledTimers.Add((key, delay)); - public void OnCancelTimer(string key) => CancelledTimers.Add(key); - public ILoggingAdapter Log => NoLogger.Instance; -} diff --git a/src/Servus.Akka.Tests/Utils/SimpleProxy.cs b/src/Servus.Akka.Tests/Utils/SimpleProxy.cs deleted file mode 100644 index 8d1e1deb4..000000000 --- a/src/Servus.Akka.Tests/Utils/SimpleProxy.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Tests.Utils; - -public sealed class SimpleProxy(ICredentials? credentials = null) : IWebProxy -{ - public ICredentials? Credentials - { - get => credentials; - set { } - } - - public Uri GetProxy(Uri destination) => new($"http://proxy.local:8080/"); - - public bool IsBypassed(Uri host) => false; -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/SlowStream.cs b/src/Servus.Akka.Tests/Utils/SlowStream.cs deleted file mode 100644 index 576a72841..000000000 --- a/src/Servus.Akka.Tests/Utils/SlowStream.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Servus.Akka.Tests.Utils; - -public sealed class SlowStream : Stream -{ - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { - await Task.Delay(TimeSpan.FromSeconds(30), ct); - return 0; - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) - { - await Task.Delay(TimeSpan.FromSeconds(30), ct); - } - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/SlowTcpConnectionFactory.cs b/src/Servus.Akka.Tests/Utils/SlowTcpConnectionFactory.cs deleted file mode 100644 index 96e40a810..000000000 --- a/src/Servus.Akka.Tests/Utils/SlowTcpConnectionFactory.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Servus.Akka.Transport; -using Servus.Akka.Transport.Quic; -using Servus.Akka.Transport.Quic.Client; -using Servus.Akka.Transport.Tcp; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Tests.Utils; - -internal sealed class SlowTcpConnectionFactory(TimeSpan delay) : ITcpConnectionFactory -{ - public async Task EstablishAsync(TransportOptions options, CancellationToken ct) - { - await Task.Delay(delay, CancellationToken.None).ConfigureAwait(false); - - var state = new ClientState(Stream.Null); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - return new ConnectionLease(handle, state, cts, ConnectionInfo.None); - } -} - -internal sealed class SlowQuicConnectionFactory(TimeSpan delay) : IQuicConnectionFactory -{ - public async Task EstablishAsync(QuicTransportOptions options, - CancellationToken ct = default) - { - await Task.Delay(delay, CancellationToken.None).ConfigureAwait(false); - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - return new QuicConnectionLease(handle, options.MaxBidirectionalStreams); - } -} - -internal sealed class MockFactory : IQuicConnectionFactory -{ - private readonly bool _shouldFail; - private readonly int _maxStreams; - - public int EstablishCount { get; private set; } - - public MockFactory(bool shouldFail = false, int maxStreams = 100) - { - _shouldFail = shouldFail; - _maxStreams = maxStreams; - } - - public Task EstablishAsync(QuicTransportOptions options, CancellationToken ct = default) - { - EstablishCount++; - if (_shouldFail) - { - return Task.FromException(new IOException("Simulated failure")); - } - - var handle = new QuicConnectionHandle( - openStream: (_, _) => Task.FromResult((Stream: (Stream)new MemoryStream(), StreamId: 0L)), - acceptInboundStream: _ => Task.FromResult<(Stream, long)?>(null), - getLocalEndPoint: () => null, - getRemoteEndPoint: () => null, - dispose: () => ValueTask.CompletedTask); - return Task.FromResult(new QuicConnectionLease(handle, options.MaxBidirectionalStreams)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/StubOps.cs b/src/Servus.Akka.Tests/Utils/StubOps.cs deleted file mode 100644 index cc1b1513a..000000000 --- a/src/Servus.Akka.Tests/Utils/StubOps.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Akka.Event; -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Utils; - -internal sealed class StubOps : ITransportOperations -{ - public readonly List PushedInbound = []; - public int PullCount; - public bool Completed; - public readonly Dictionary Timers = new(); - public readonly HashSet CancelledTimers = []; - - public void OnPushInbound(ITransportInbound item) => PushedInbound.Add(item); - public void OnSignalPullOutbound() => PullCount++; - public void OnCompleteStage() => Completed = true; - public void OnScheduleTimer(string key, TimeSpan delay) => Timers[key] = delay; - public void OnCancelTimer(string key) => CancelledTimers.Add(key); - public ILoggingAdapter Log => NoLogger.Instance; -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/Utils/TestPoolingStrategies.cs b/src/Servus.Akka.Tests/Utils/TestPoolingStrategies.cs deleted file mode 100644 index ba1346ec4..000000000 --- a/src/Servus.Akka.Tests/Utils/TestPoolingStrategies.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Servus.Akka.Transport; - -namespace Servus.Akka.Tests.Utils; - -internal sealed class TestPoolingStrategy : IPoolingStrategy -{ - public PoolAction OnDisconnect(object lease, DisconnectReason reason) => PoolAction.Dispose; - public PoolAction OnUpstreamFinish(object lease) => PoolAction.Reuse; -} - -internal sealed class ReusablePoolingStrategy : IPoolingStrategy -{ - public PoolAction OnDisconnect(object lease, DisconnectReason reason) => PoolAction.Reuse; - public PoolAction OnUpstreamFinish(object lease) => PoolAction.Reuse; -} diff --git a/src/Servus.Akka.Tests/Utils/TestProxy.cs b/src/Servus.Akka.Tests/Utils/TestProxy.cs deleted file mode 100644 index 3b51146ab..000000000 --- a/src/Servus.Akka.Tests/Utils/TestProxy.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Tests.Utils; - -public sealed class TestProxy(Uri? proxyUri, string? bypassedHost = null, ICredentials? credentials = null) - : IWebProxy -{ - public ICredentials? Credentials { get; set; } = credentials; - - public Uri? GetProxy(Uri destination) => proxyUri; - - public bool IsBypassed(Uri host) - { - if (bypassedHost is null) - { - return false; - } - - return host.Host == bypassedHost; - } -} \ No newline at end of file diff --git a/src/Servus.Akka.Tests/xunit.runner.json b/src/Servus.Akka.Tests/xunit.runner.json deleted file mode 100644 index 1a57b530a..000000000 --- a/src/Servus.Akka.Tests/xunit.runner.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeTestCollections": true, - "parallelizeAssembly": false, - "maxParallelThreads": 2 -} diff --git a/src/Servus.Akka/Servus.Akka.csproj b/src/Servus.Akka/Servus.Akka.csproj deleted file mode 100644 index d96d43822..000000000 --- a/src/Servus.Akka/Servus.Akka.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - false - - CA1416 - - - - - - - - - - - - diff --git a/src/Servus.Akka/Sse/ServerSentEvent.cs b/src/Servus.Akka/Sse/ServerSentEvent.cs deleted file mode 100644 index ec8eeda4b..000000000 --- a/src/Servus.Akka/Sse/ServerSentEvent.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Servus.Akka.Sse; - -public sealed record ServerSentEvent( - string Data, - string? EventType = null, - string? Id = null, - TimeSpan? Retry = null); diff --git a/src/Servus.Akka/Sse/SseFormatterFlow.cs b/src/Servus.Akka/Sse/SseFormatterFlow.cs deleted file mode 100644 index 936b0df82..000000000 --- a/src/Servus.Akka/Sse/SseFormatterFlow.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Buffers; -using System.Text; -using Akka; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Sse; - -public static class SseFormatterFlow -{ - private const byte Lf = (byte)'\n'; - - public static Flow, NotUsed> Instance { get; } - = Flow.Create().Select(Format); - - private static ReadOnlyMemory Format(ServerSentEvent evt) - { - var size = EstimateSize(evt); - var buffer = ArrayPool.Shared.Rent(size); - var pos = 0; - - if (evt.EventType is not null && evt.EventType != "message") - { - pos += WriteField(buffer.AsSpan(pos), "event: "u8, evt.EventType.AsSpan()); - } - - WriteLinesWithPrefix(buffer, ref pos, "data: "u8, evt.Data.AsSpan()); - buffer[pos++] = Lf; - - if (evt.Id is not null && !evt.Id.Contains('\0') && !evt.Id.AsSpan().ContainsAny('\r', '\n')) - { - pos += WriteField(buffer.AsSpan(pos), "id: "u8, evt.Id.AsSpan()); - } - - if (evt.Retry is not null && evt.Retry.Value >= TimeSpan.Zero) - { - Span retryBuf = stackalloc byte[20]; - ((long)evt.Retry.Value.TotalMilliseconds).TryFormat(retryBuf, out var retryLen); - pos += WriteFieldBytes(buffer.AsSpan(pos), "retry: "u8, retryBuf[..retryLen]); - } - - buffer[pos++] = Lf; - - var result = new byte[pos]; - buffer.AsSpan(0, pos).CopyTo(result); - ArrayPool.Shared.Return(buffer); - - return result.AsMemory(); - } - - private static int WriteField(Span dest, ReadOnlySpan prefix, ReadOnlySpan value) - { - prefix.CopyTo(dest); - var written = prefix.Length; - written += Encoding.UTF8.GetBytes(value, dest[written..]); - dest[written++] = Lf; - return written; - } - - private static int WriteFieldBytes(Span dest, ReadOnlySpan prefix, ReadOnlySpan value) - { - prefix.CopyTo(dest); - var written = prefix.Length; - value.CopyTo(dest[written..]); - written += value.Length; - dest[written++] = Lf; - return written; - } - - private static void WriteLinesWithPrefix(byte[] buffer, ref int pos, ReadOnlySpan prefix, ReadOnlySpan data) - { - while (true) - { - prefix.CopyTo(buffer.AsSpan(pos)); - pos += prefix.Length; - - var nlIndex = data.IndexOfAny('\r', '\n'); - if (nlIndex < 0) - { - break; - } - - pos += Encoding.UTF8.GetBytes(data[..nlIndex], buffer.AsSpan(pos)); - buffer[pos++] = Lf; - - if (data[nlIndex] == '\r' && nlIndex + 1 < data.Length && data[nlIndex + 1] == '\n') - { - data = data[(nlIndex + 2)..]; - } - else - { - data = data[(nlIndex + 1)..]; - } - } - - pos += Encoding.UTF8.GetBytes(data, buffer.AsSpan(pos)); - } - - private static int EstimateSize(ServerSentEvent evt) - { - var size = Encoding.UTF8.GetMaxByteCount(evt.Data.Length) + evt.Data.Length + 32; - if (evt.EventType is not null) - { - size += Encoding.UTF8.GetMaxByteCount(evt.EventType.Length) + 10; - } - - if (evt.Id is not null) - { - size += Encoding.UTF8.GetMaxByteCount(evt.Id.Length) + 6; - } - - if (evt.Retry is not null) - { - size += 28; - } - - return size; - } -} diff --git a/src/Servus.Akka/Sse/SseParserFlow.cs b/src/Servus.Akka/Sse/SseParserFlow.cs deleted file mode 100644 index b26398302..000000000 --- a/src/Servus.Akka/Sse/SseParserFlow.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System.Text; -using Akka; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.Streams.Stage; - -namespace Servus.Akka.Sse; - -public static class SseParserFlow -{ - public static Flow, ServerSentEvent, NotUsed> Instance { get; } - = Flow.FromGraph(new SseParserStage()); -} - -internal sealed class SseParserStage : GraphStage, ServerSentEvent>> -{ - private readonly Inlet> _in = new("SseParserStage.in"); - private readonly Outlet _out = new("SseParserStage.out"); - - public override FlowShape, ServerSentEvent> Shape => new(_in, _out); - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new SseParserLogic(this); - - private sealed class SseParserLogic : GraphStageLogic - { - private readonly SseParserStage _stage; - private readonly StringBuilder _lineBuffer = new(); - private readonly StringBuilder _dataAccumulator = new(); - private readonly Queue _pending = new(); - - private string? _eventType; - private string? _id; - private TimeSpan? _retry; - private bool _bomChecked; - private bool _hasData; - private bool _upstreamFinished; - private bool _upstreamWaiting; - - public SseParserLogic(SseParserStage stage) : base(stage.Shape) - { - _stage = stage; - SetHandler(stage._in, - onPush: () => - { - _upstreamWaiting = false; - var chunk = Grab(stage._in); - var bytes = chunk.ToArray(); - - var startIndex = 0; - if (!_bomChecked) - { - _bomChecked = true; - if (bytes is [0xEF, 0xBB, 0xBF, ..]) - { - startIndex = 3; - } - } - - var text = Encoding.UTF8.GetString(bytes, startIndex, bytes.Length - startIndex); - ProcessText(text); - DrainPending(stage); - }, - onUpstreamFinish: () => - { - _upstreamFinished = true; - - if (_lineBuffer.Length > 0) - { - ProcessField(_lineBuffer.ToString()); - _lineBuffer.Clear(); - } - - if (_hasData) - { - var data = _dataAccumulator.ToString(); - if (data.Length > 0 && data[^1] == '\n') - { - data = data[..^1]; - } - - var evt = new ServerSentEvent( - Data: data, - EventType: _eventType ?? "message", - Id: _id, - Retry: _retry); - _pending.Enqueue(evt); - } - - DrainPending(stage); - }); - - SetHandler(stage._out, - onPull: () => - { - DrainPending(stage); - }); - } - - public override void PreStart() - { - Pull(_stage._in); - _upstreamWaiting = true; - } - - private void DrainPending(SseParserStage stage) - { - while (IsAvailable(stage._out) && _pending.Count > 0) - { - var evt = _pending.Dequeue(); - Push(stage._out, evt); - } - - if (!IsAvailable(stage._out)) - { - return; - } - - if (_upstreamFinished && _pending.Count == 0) - { - CompleteStage(); - } - else if (!_upstreamWaiting && !_upstreamFinished) - { - Pull(stage._in); - _upstreamWaiting = true; - } - } - - private void ProcessText(string text) - { - var i = 0; - while (i < text.Length) - { - var lineEnd = -1; - var endLength = 0; - - for (var j = i; j < text.Length; j++) - { - if (j < text.Length - 1 && text[j] == '\r' && text[j + 1] == '\n') - { - lineEnd = j; - endLength = 2; - break; - } - - if (text[j] == '\r' || text[j] == '\n') - { - lineEnd = j; - endLength = 1; - break; - } - } - - if (lineEnd >= 0) - { - var lineContent = text.Substring(i, lineEnd - i); - _lineBuffer.Append(lineContent); - var completeLine = _lineBuffer.ToString(); - _lineBuffer.Clear(); - - if (completeLine == string.Empty) - { - if (_hasData) - { - var data = _dataAccumulator.ToString(); - if (data.Length > 0 && data[^1] == '\n') - { - data = data[..^1]; - } - - var evt = new ServerSentEvent( - Data: data, - EventType: _eventType ?? "message", - Id: _id, - Retry: _retry); - _pending.Enqueue(evt); - } - - ResetEvent(); - } - else if (!completeLine.StartsWith(":")) - { - ProcessField(completeLine); - } - - i = lineEnd + endLength; - } - else - { - var remaining = text[i..]; - _lineBuffer.Append(remaining); - break; - } - } - } - - private void ProcessField(string line) - { - string fieldName; - string fieldValue; - - var colonIndex = line.IndexOf(':'); - if (colonIndex < 0) - { - fieldName = line; - fieldValue = string.Empty; - } - else - { - fieldName = line[..colonIndex]; - var valueStart = colonIndex + 1; - - if (valueStart < line.Length && line[valueStart] == ' ') - { - valueStart++; - } - - fieldValue = valueStart < line.Length ? line[valueStart..] : string.Empty; - } - - switch (fieldName) - { - case "data": - if (_dataAccumulator.Length > 0) - { - _dataAccumulator.Append('\n'); - } - _dataAccumulator.Append(fieldValue); - _hasData = true; - break; - - case "event": - _eventType = fieldValue; - break; - - case "id": - if (!fieldValue.Contains('\0')) - { - _id = fieldValue; - } - break; - - case "retry": - if (int.TryParse(fieldValue, out var retryMs)) - { - _retry = TimeSpan.FromMilliseconds(retryMs); - } - break; - } - } - - private void ResetEvent() - { - _dataAccumulator.Clear(); - _eventType = null; - _id = null; - _retry = null; - _hasData = false; - } - } -} diff --git a/src/Servus.Akka/Streams/IO/PipeSink.cs b/src/Servus.Akka/Streams/IO/PipeSink.cs deleted file mode 100644 index 3ffe3ba82..000000000 --- a/src/Servus.Akka/Streams/IO/PipeSink.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.IO.Pipelines; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Streams.IO; - -public static class PipeSink -{ - public static Sink, Task> To(PipeWriter writer) - { - return Sink.FromGraph(new PipeSinkStage(writer)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Streams/IO/PipeSinkStage.cs b/src/Servus.Akka/Streams/IO/PipeSinkStage.cs deleted file mode 100644 index 22b0bf87b..000000000 --- a/src/Servus.Akka/Streams/IO/PipeSinkStage.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.IO.Pipelines; -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Streams.IO; - -internal sealed class PipeSinkStage : GraphStageWithMaterializedValue>, Task> -{ - private readonly PipeWriter _writer; - private readonly Inlet> _in = new("PipeWriterSink.In"); - - public PipeSinkStage(PipeWriter writer) - { - _writer = writer; - Shape = new SinkShape>(_in); - } - - public override SinkShape> Shape { get; } - - public override ILogicAndMaterializedValue CreateLogicAndMaterializedValue(Attributes inheritedAttributes) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var logic = new Logic(this, tcs); - return new LogicAndMaterializedValue(logic, tcs.Task); - } - - private sealed record FlushCompleted(FlushResult Result); - - private sealed record FlushFailed(Exception Error); - - [ExcludeFromCodeCoverage] - private sealed class Logic : GraphStageLogic - { - private readonly PipeSinkStage _stage; - private readonly TaskCompletionSource _tcs; - private IActorRef _stageActor = ActorRefs.Nobody; - - public Logic(PipeSinkStage stage, TaskCompletionSource tcs) : base(stage.Shape) - { - _stage = stage; - _tcs = tcs; - - SetHandler(stage._in, - onPush: OnPush, - onUpstreamFinish: () => - { - _stage._writer.Complete(); - _tcs.TrySetResult(); - CompleteStage(); - }, - onUpstreamFailure: ex => - { - _stage._writer.Complete(ex); - _tcs.TrySetException(ex); - FailStage(ex); - }); - } - - public override void PreStart() - { - _stageActor = GetStageActor(OnMessage).Ref; - Pull(_stage._in); - } - - private void OnPush() - { - var chunk = Grab(_stage._in); - if (chunk.Length == 0) - { - Pull(_stage._in); - return; - } - - var vt = _stage._writer.WriteAsync(chunk); - - if (vt.IsCompleted) - { - ProcessFlushResult(vt.Result); - return; - } - - _ = vt.PipeTo(_stageActor, - success: result => new FlushCompleted(result), - failure: ex => new FlushFailed(ex)); - } - - private void OnMessage((IActorRef sender, object msg) args) - { - switch (args.msg) - { - case FlushCompleted completed: - ProcessFlushResult(completed.Result); - break; - - case FlushFailed failed: - _stage._writer.Complete(failed.Error); - _tcs.TrySetException(failed.Error); - CompleteStage(); - break; - } - } - - private void ProcessFlushResult(FlushResult result) - { - if (result.IsCompleted || result.IsCanceled) - { - _stage._writer.Complete(); - _tcs.TrySetResult(); - CompleteStage(); - return; - } - - Pull(_stage._in); - } - - public override void PostStop() - { - _stage._writer.CancelPendingFlush(); - _tcs.TrySetCanceled(); - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Streams/IO/PipeSource.cs b/src/Servus.Akka/Streams/IO/PipeSource.cs deleted file mode 100644 index 57e18f174..000000000 --- a/src/Servus.Akka/Streams/IO/PipeSource.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.IO.Pipelines; -using Akka; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Streams.IO; - -public static class PipeSource -{ - public static Source, NotUsed> From(PipeReader reader) - { - return Source.FromGraph(new PipeSourceStage(reader)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Streams/IO/PipeSourceStage.cs b/src/Servus.Akka/Streams/IO/PipeSourceStage.cs deleted file mode 100644 index 688ddc3d8..000000000 --- a/src/Servus.Akka/Streams/IO/PipeSourceStage.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.IO.Pipelines; -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Streams.IO; - -internal sealed class PipeSourceStage : GraphStage>> -{ - private readonly PipeReader _reader; - private readonly Outlet> _out = new("PipeReaderSource.Out"); - - public PipeSourceStage(PipeReader reader) - { - _reader = reader; - Shape = new SourceShape>(_out); - } - - public override SourceShape> Shape { get; } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new Logic(this); - - private sealed record ReadCompleted(ReadResult Result); - - private sealed record ReadFailed(Exception Error); - - [ExcludeFromCodeCoverage] - private sealed class Logic : GraphStageLogic - { - private readonly PipeSourceStage _stage; - private IActorRef _stageActor = ActorRefs.Nobody; - private bool _completing; - - public Logic(PipeSourceStage stage) : base(stage.Shape) - { - _stage = stage; - SetHandler(stage._out, onPull: OnPull); - } - - public override void PreStart() - { - _stageActor = GetStageActor(OnMessage).Ref; - } - - private void OnPull() - { - var vt = _stage._reader.ReadAsync(); - - if (vt.IsCompleted) - { - ProcessReadResult(vt.Result); - return; - } - - vt.PipeTo(_stageActor, - success: result => new ReadCompleted(result), - failure: ex => new ReadFailed(ex)); - } - - private void OnMessage((IActorRef sender, object msg) args) - { - switch (args.msg) - { - case ReadCompleted completed: - ProcessReadResult(completed.Result); - break; - - case ReadFailed failed: - _stage._reader.Complete(failed.Error); - FailStage(failed.Error); - break; - } - } - - private void ProcessReadResult(ReadResult result) - { - var buffer = result.Buffer; - - if (buffer.IsEmpty && result.IsCompleted) - { - _stage._reader.AdvanceTo(buffer.End); - _stage._reader.Complete(); - CompleteStage(); - return; - } - - if (buffer.IsEmpty) - { - _stage._reader.AdvanceTo(buffer.Start, buffer.End); - OnPull(); - return; - } - - byte[] bytes; - if (buffer.IsSingleSegment) - { - bytes = buffer.FirstSpan.ToArray(); - } - else - { - bytes = new byte[buffer.Length]; - var offset = 0; - foreach (var segment in buffer) - { - segment.Span.CopyTo(bytes.AsSpan(offset)); - offset += segment.Length; - } - } - - _stage._reader.AdvanceTo(buffer.End); - - if (result.IsCompleted) - { - _completing = true; - } - - Push(_stage._out, bytes.AsMemory()); - - if (_completing) - { - _stage._reader.Complete(); - CompleteStage(); - } - } - - public override void PostStop() - { - _stage._reader.Complete(); - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Streams/IO/StreamSink.cs b/src/Servus.Akka/Streams/IO/StreamSink.cs deleted file mode 100644 index 02a6c5b19..000000000 --- a/src/Servus.Akka/Streams/IO/StreamSink.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Akka.Streams.Dsl; - -namespace Servus.Akka.Streams.IO; - -public static class StreamSink -{ - public static Sink, Task> To(Stream stream) - { - return Sink.FromGraph(new StreamSinkStage(stream)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Streams/IO/StreamSinkStage.cs b/src/Servus.Akka/Streams/IO/StreamSinkStage.cs deleted file mode 100644 index 0c8593576..000000000 --- a/src/Servus.Akka/Streams/IO/StreamSinkStage.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Streams.IO; - -internal sealed class StreamSinkStage : GraphStageWithMaterializedValue>, Task> -{ - private readonly Stream _stream; - private readonly Inlet> _in = new("StreamSink.In"); - - public StreamSinkStage(Stream stream) - { - _stream = stream; - Shape = new SinkShape>(_in); - } - - public override SinkShape> Shape { get; } - - public override ILogicAndMaterializedValue CreateLogicAndMaterializedValue(Attributes inheritedAttributes) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var logic = new Logic(this, tcs); - return new LogicAndMaterializedValue(logic, tcs.Task); - } - - private sealed record WriteCompleted; - - private sealed record WriteFailed(Exception Error); - - [ExcludeFromCodeCoverage] - private sealed class Logic : GraphStageLogic - { - private readonly StreamSinkStage _stage; - private readonly TaskCompletionSource _tcs; - private IActorRef _stageActor = ActorRefs.Nobody; - - public Logic(StreamSinkStage stage, TaskCompletionSource tcs) : base(stage.Shape) - { - _stage = stage; - _tcs = tcs; - - SetHandler(stage._in, - onPush: OnPush, - onUpstreamFinish: () => - { - var vt = _stage._stream.FlushAsync(); - - if (vt.IsCompleted) - { - _tcs.TrySetResult(); - CompleteStage(); - return; - } - - vt.PipeTo(_stageActor, - success: () => new WriteCompleted(), - failure: ex => new WriteFailed(ex)); - }, - onUpstreamFailure: ex => - { - _tcs.TrySetException(ex); - FailStage(ex); - }); - } - - public override void PreStart() - { - _stageActor = GetStageActor(OnMessage).Ref; - Pull(_stage._in); - } - - private void OnPush() - { - var chunk = Grab(_stage._in); - if (chunk.Length == 0) - { - Pull(_stage._in); - return; - } - - var vt = _stage._stream.WriteAsync(chunk); - - if (vt.IsCompleted) - { - Pull(_stage._in); - return; - } - - vt.AsTask().PipeTo(_stageActor, - success: () => new WriteCompleted(), - failure: ex => new WriteFailed(ex)); - } - - private void OnMessage((IActorRef sender, object msg) args) - { - switch (args.msg) - { - case WriteCompleted: - if (IsClosed(_stage._in)) - { - _tcs.TrySetResult(); - CompleteStage(); - } - else - { - Pull(_stage._in); - } - break; - - case WriteFailed failed: - _tcs.TrySetException(failed.Error); - FailStage(failed.Error); - break; - } - } - - public override void PostStop() - { - _tcs.TrySetCanceled(); - } - } -} diff --git a/src/Servus.Akka/Streams/IO/StreamSource.cs b/src/Servus.Akka/Streams/IO/StreamSource.cs deleted file mode 100644 index e3fe8739d..000000000 --- a/src/Servus.Akka/Streams/IO/StreamSource.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Akka; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Streams.IO; - -public static class StreamSource -{ - public static Source, NotUsed> From(Stream stream, int bufferSize = 8 * 1024) - { - return Source.FromGraph(new StreamSourceStage(stream, bufferSize)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Streams/IO/StreamSourceStage.cs b/src/Servus.Akka/Streams/IO/StreamSourceStage.cs deleted file mode 100644 index 6bf2e3fc5..000000000 --- a/src/Servus.Akka/Streams/IO/StreamSourceStage.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Akka.Actor; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Streams.IO; - -internal sealed class StreamSourceStage : GraphStage>> -{ - private readonly Stream _stream; - private readonly int _bufferSize; - private readonly Outlet> _out = new("StreamSource.Out"); - - public StreamSourceStage(Stream stream, int bufferSize = 8 * 1024) - { - _stream = stream; - _bufferSize = bufferSize; - Shape = new SourceShape>(_out); - } - - public override SourceShape> Shape { get; } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new Logic(this); - - private sealed record ReadCompleted(int BytesRead); - - private sealed record ReadFailed(Exception Error); - - [ExcludeFromCodeCoverage] - private sealed class Logic : GraphStageLogic - { - private readonly StreamSourceStage _stage; - private IActorRef _stageActor = ActorRefs.Nobody; - private byte[] _readBuffer = []; - - public Logic(StreamSourceStage stage) : base(stage.Shape) - { - _stage = stage; - SetHandler(stage._out, onPull: OnPull); - } - - public override void PreStart() - { - _stageActor = GetStageActor(OnMessage).Ref; - _readBuffer = new byte[_stage._bufferSize]; - } - - private void OnPull() - { - var vt = _stage._stream.ReadAsync(_readBuffer); - - if (vt.IsCompleted) - { - ProcessBytesRead(vt.Result); - return; - } - - vt.PipeTo(_stageActor, - success: bytesRead => new ReadCompleted(bytesRead), - failure: ex => new ReadFailed(ex)); - } - - private void OnMessage((IActorRef sender, object msg) args) - { - switch (args.msg) - { - case ReadCompleted completed: - ProcessBytesRead(completed.BytesRead); - break; - - case ReadFailed failed: - FailStage(failed.Error); - break; - } - } - - private void ProcessBytesRead(int bytesRead) - { - if (bytesRead == 0) - { - CompleteStage(); - return; - } - - var copy = new byte[bytesRead]; - _readBuffer.AsSpan(0, bytesRead).CopyTo(copy); - Push(_stage._out, copy.AsMemory()); - } - - public override void PostStop() - { - _readBuffer = []; - } - } -} diff --git a/src/Servus.Akka/Transport/ClientCertificateMode.cs b/src/Servus.Akka/Transport/ClientCertificateMode.cs deleted file mode 100644 index f568464b3..000000000 --- a/src/Servus.Akka/Transport/ClientCertificateMode.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Servus.Akka.Transport; - -public enum ClientCertificateMode -{ - NoCertificate = 0, - AllowCertificate = 1, - RequireCertificate = 2, - DelayCertificate = 3 -} diff --git a/src/Servus.Akka/Transport/ConnectionInfo.cs b/src/Servus.Akka/Transport/ConnectionInfo.cs deleted file mode 100644 index 9b71fe8f7..000000000 --- a/src/Servus.Akka/Transport/ConnectionInfo.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Transport; - -public sealed record ConnectionInfo( - EndPoint Local, - EndPoint Remote, - TransportProtocol Protocol, - SecurityInfo? Security = null) -{ - public static readonly ConnectionInfo None = new( - new IPEndPoint(IPAddress.None, 0), - new IPEndPoint(IPAddress.None, 0), - TransportProtocol.None); -} diff --git a/src/Servus.Akka/Transport/DisconnectReason.cs b/src/Servus.Akka/Transport/DisconnectReason.cs deleted file mode 100644 index 36bbb9802..000000000 --- a/src/Servus.Akka/Transport/DisconnectReason.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Servus.Akka.Transport; - -public enum DisconnectReason -{ - Graceful, - Timeout, - Error, - Evicted, - Transient -} diff --git a/src/Servus.Akka/Transport/IListenerFactory.cs b/src/Servus.Akka/Transport/IListenerFactory.cs deleted file mode 100644 index 1d3da4ab2..000000000 --- a/src/Servus.Akka/Transport/IListenerFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Akka; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Transport; - -public interface IListenerFactory -{ - Source, Task> Bind(ListenerOptions options); -} diff --git a/src/Servus.Akka/Transport/IPoolingStrategy.cs b/src/Servus.Akka/Transport/IPoolingStrategy.cs deleted file mode 100644 index 59ce26c44..000000000 --- a/src/Servus.Akka/Transport/IPoolingStrategy.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Servus.Akka.Transport; - -public interface IPoolingStrategy -{ - PoolAction OnDisconnect(object lease, DisconnectReason reason); - PoolAction OnUpstreamFinish(object lease); -} diff --git a/src/Servus.Akka/Transport/ITransportFactory.cs b/src/Servus.Akka/Transport/ITransportFactory.cs deleted file mode 100644 index 07f21c66b..000000000 --- a/src/Servus.Akka/Transport/ITransportFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Akka; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Transport; - -public interface ITransportFactory -{ - Flow Create(); -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/ITransportInbound.cs b/src/Servus.Akka/Transport/ITransportInbound.cs deleted file mode 100644 index 005b65fb4..000000000 --- a/src/Servus.Akka/Transport/ITransportInbound.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Transport; - -public interface ITransportInbound; - -public sealed record TransportConnected(ConnectionInfo Info) : ITransportInbound; - -public sealed record TransportDisconnected(DisconnectReason Reason) : ITransportInbound; - -public sealed record TransportError(Exception Exception, bool Fatal) : ITransportInbound; - -public sealed record StreamOpened(StreamTarget Id, StreamDirection Direction) : ITransportInbound; - -public sealed record StreamClosed(StreamTarget Id, DisconnectReason Reason) : ITransportInbound; - -public sealed record StreamReadCompleted(StreamTarget Id) : ITransportInbound; - -public sealed record ServerStreamAccepted(StreamTarget Id, StreamDirection Direction) : ITransportInbound; - -public sealed record ConnectionMigrationDetected(EndPoint OldEndPoint, EndPoint NewEndPoint) : ITransportInbound; diff --git a/src/Servus.Akka/Transport/ITransportOperations.cs b/src/Servus.Akka/Transport/ITransportOperations.cs deleted file mode 100644 index f921aa71e..000000000 --- a/src/Servus.Akka/Transport/ITransportOperations.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Akka.Event; - -namespace Servus.Akka.Transport; - -public interface ITransportOperations -{ - void OnPushInbound(ITransportInbound item); - void OnSignalPullOutbound(); - void OnCompleteStage(); - void OnScheduleTimer(string key, TimeSpan delay); - void OnCancelTimer(string key); - ILoggingAdapter Log { get; } -} diff --git a/src/Servus.Akka/Transport/ITransportOutbound.cs b/src/Servus.Akka/Transport/ITransportOutbound.cs deleted file mode 100644 index ffba2770b..000000000 --- a/src/Servus.Akka/Transport/ITransportOutbound.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Servus.Akka.Transport; - -public interface ITransportOutbound; - -public sealed record ConnectTransport(TransportOptions Options) : ITransportOutbound; - -public sealed record DisconnectTransport(DisconnectReason Reason) : ITransportOutbound; - -public sealed record OpenStream(StreamTarget StreamId, StreamDirection Direction) : ITransportOutbound; - -public sealed record CloseStream(StreamTarget StreamId) : ITransportOutbound; - -public sealed record CompleteWrites(StreamTarget StreamId) : ITransportOutbound; - -public sealed record ResetStream(StreamTarget StreamId, long ErrorCode = 0) : ITransportOutbound; - -public sealed record TransportData(TransportBuffer Buffer) : ITransportOutbound, ITransportInbound; - -public sealed record MultiplexedData(TransportBuffer Buffer, StreamTarget StreamId) : ITransportOutbound, ITransportInbound; diff --git a/src/Servus.Akka/Transport/ListenerOptions.cs b/src/Servus.Akka/Transport/ListenerOptions.cs deleted file mode 100644 index 4a0fa5e96..000000000 --- a/src/Servus.Akka/Transport/ListenerOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Servus.Akka.Transport; - -public abstract record ListenerOptions -{ - public required string Host { get; init; } - public required ushort Port { get; init; } - public int Backlog { get; init; } = int.MaxValue; - public int? SocketSendBufferSize { get; init; } - public int? SocketReceiveBufferSize { get; init; } -} diff --git a/src/Servus.Akka/Transport/PipeMode.cs b/src/Servus.Akka/Transport/PipeMode.cs deleted file mode 100644 index ba471e4f5..000000000 --- a/src/Servus.Akka/Transport/PipeMode.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Servus.Akka.Transport; - -internal enum PipeMode -{ - Bidirectional, - WriteOnly, - ReadOnly -} diff --git a/src/Servus.Akka/Transport/PoolAction.cs b/src/Servus.Akka/Transport/PoolAction.cs deleted file mode 100644 index 6a90ed5e2..000000000 --- a/src/Servus.Akka/Transport/PoolAction.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Servus.Akka.Transport; - -public enum PoolAction -{ - Reuse, - Dispose -} diff --git a/src/Servus.Akka/Transport/PoolConfigRegistry.cs b/src/Servus.Akka/Transport/PoolConfigRegistry.cs deleted file mode 100644 index f1b681de1..000000000 --- a/src/Servus.Akka/Transport/PoolConfigRegistry.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Servus.Akka.Transport; - -public sealed class PoolConfigRegistry -{ - private readonly Dictionary _configs = new(StringComparer.OrdinalIgnoreCase); - private readonly TcpPoolConfig _default; - - public PoolConfigRegistry(TcpPoolConfig defaultConfig) - { - _default = defaultConfig ?? throw new ArgumentNullException(nameof(defaultConfig)); - } - - public PoolConfigRegistry Register(string poolKey, TcpPoolConfig config) - { - _configs[poolKey] = config ?? throw new ArgumentNullException(nameof(config)); - return this; - } - - public TcpPoolConfig Resolve(string? poolKey) - { - if (poolKey is not null && _configs.TryGetValue(poolKey, out var config)) - { - return config; - } - - return _default; - } -} diff --git a/src/Servus.Akka/Transport/Quic/Client/IQuicConnectionFactory.cs b/src/Servus.Akka/Transport/Quic/Client/IQuicConnectionFactory.cs deleted file mode 100644 index 0e7707d7b..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/IQuicConnectionFactory.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Servus.Akka.Transport.Quic.Client; - -internal interface IQuicConnectionFactory -{ - Task EstablishAsync(QuicTransportOptions options, CancellationToken ct); -} diff --git a/src/Servus.Akka/Transport/Quic/Client/QuicClientProvider.cs b/src/Servus.Akka/Transport/Quic/Client/QuicClientProvider.cs deleted file mode 100644 index b4a12e668..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/QuicClientProvider.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Net; -using System.Net.Quic; -using System.Net.Security; - -namespace Servus.Akka.Transport.Quic.Client; - -internal sealed class QuicClientProvider : IAsyncDisposable -{ - private readonly QuicTransportOptions _options; - private QuicConnection? _connection; - private readonly SemaphoreSlim _connectLock = new(1, 1); - - public QuicClientProvider(QuicTransportOptions options) - { - _options = options; - } - - public EndPoint? LocalEndPoint => _connection?.LocalEndPoint; - public EndPoint? RemoteEndPoint => _connection?.RemoteEndPoint; - - public async Task GetStreamAsync(CancellationToken ct = default) - { - var connection = await EnsureConnectedAsync(ct).ConfigureAwait(false); - return await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional, ct).ConfigureAwait(false); - } - - public async Task GetUnidirectionalStreamAsync(CancellationToken ct = default) - { - var connection = await EnsureConnectedAsync(ct).ConfigureAwait(false); - return await connection.OpenOutboundStreamAsync(QuicStreamType.Unidirectional, ct).ConfigureAwait(false); - } - - public async Task AcceptInboundStreamAsync(CancellationToken ct = default) - { - var connection = await EnsureConnectedAsync(ct).ConfigureAwait(false); - return await connection.AcceptInboundStreamAsync(ct).ConfigureAwait(false); - } - - internal Task ConnectAsync(CancellationToken ct) => EnsureConnectedAsync(ct); - - private async Task EnsureConnectedAsync(CancellationToken ct) - { - var existing = _connection; - if (existing is not null) - { - return existing; - } - - await _connectLock.WaitAsync(ct).ConfigureAwait(false); - try - { - existing = _connection; - if (existing is not null) - { - return existing; - } - - if (string.IsNullOrEmpty(_options.Host)) - { - throw new InvalidOperationException("QUIC connections require a non-empty hostname for TLS SNI."); - } - - EndPoint remoteEndPoint = IPAddress.TryParse(_options.Host, out var ip) - ? new IPEndPoint(ip, _options.Port) - : new DnsEndPoint(_options.Host, _options.Port); - - var clientConnectionOptions = new QuicClientConnectionOptions - { - RemoteEndPoint = remoteEndPoint, - DefaultStreamErrorCode = 0x0100, - DefaultCloseErrorCode = 0x0100, - MaxInboundBidirectionalStreams = _options.MaxBidirectionalStreams, - MaxInboundUnidirectionalStreams = _options.MaxUnidirectionalStreams, - IdleTimeout = _options.IdleTimeout, - ClientAuthenticationOptions = new SslClientAuthenticationOptions - { - TargetHost = _options.TargetHost ?? _options.Host, - ApplicationProtocols = _options.ApplicationProtocols, - RemoteCertificateValidationCallback = _options.ServerCertificateValidationCallback, - EnabledSslProtocols = _options.EnabledSslProtocols, - ClientCertificates = _options.ClientCertificates - } - }; - - var connection = await QuicConnection.ConnectAsync(clientConnectionOptions, ct).ConfigureAwait(false); - _connection = connection; - return connection; - } - finally - { - _connectLock.Release(); - } - } - - public async ValueTask DisposeAsync() - { - var connection = Interlocked.Exchange(ref _connection, null); - if (connection is not null) - { - await connection.DisposeAsync().ConfigureAwait(false); - } - - _connectLock.Dispose(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionFactory.cs b/src/Servus.Akka/Transport/Quic/Client/QuicConnectionFactory.cs deleted file mode 100644 index 382ce2016..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionFactory.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Servus.Akka.Transport.Quic.Client; - -internal sealed class QuicConnectionFactory : IQuicConnectionFactory -{ - public static readonly QuicConnectionFactory Instance = new(); - - public async Task EstablishAsync( - QuicTransportOptions options, CancellationToken ct = default) - { - var provider = new QuicClientProvider(options); - await provider.ConnectAsync(ct).ConfigureAwait(false); - - var handle = new QuicConnectionHandle( - openStream: async (direction, token) => - { - var stream = direction == StreamDirection.Bidirectional - ? await provider.GetStreamAsync(token).ConfigureAwait(false) - : await provider.GetUnidirectionalStreamAsync(token).ConfigureAwait(false); - var streamId = stream is System.Net.Quic.QuicStream qs ? qs.Id : -1; - return (stream, streamId); - }, - acceptInboundStream: async token => - { - Stream stream; - try - { - stream = await provider.AcceptInboundStreamAsync(token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return null; - } - catch (Exception) - { - return null; - } - var streamId = stream is System.Net.Quic.QuicStream qs ? qs.Id : -1; - return (stream, streamId); - }, - getLocalEndPoint: () => provider.LocalEndPoint, - getRemoteEndPoint: () => provider.RemoteEndPoint, - dispose: () => provider.DisposeAsync()); - - return new QuicConnectionLease(handle, options.MaxBidirectionalStreams); - } -} - -#pragma warning restore CA1416 diff --git a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionLease.cs b/src/Servus.Akka/Transport/Quic/Client/QuicConnectionLease.cs deleted file mode 100644 index ae3f89dc5..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionLease.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Servus.Akka.Transport.Quic.Client; - -internal sealed class QuicConnectionLease : IAsyncDisposable -{ - private readonly long _createdTicks = Environment.TickCount64; - private readonly int _maxConcurrentStreams; - private bool _alive = true; - - public QuicConnectionLease(QuicConnectionHandle handle, int maxConcurrentStreams) - { - Handle = handle; - _maxConcurrentStreams = maxConcurrentStreams; - } - - public QuicConnectionHandle Handle { get; } - - public int ActiveStreams { get; private set; } - - public DateTime LastActivity { get; private set; } = DateTime.UtcNow; - - public bool IsAlive() => _alive; - - public bool IsExpired(TimeSpan maxLifetime) - { - if (maxLifetime == Timeout.InfiniteTimeSpan) - { - return false; - } - - return Environment.TickCount64 - _createdTicks > (long)maxLifetime.TotalMilliseconds; - } - - public bool CanAcceptStream() => _alive && ActiveStreams < _maxConcurrentStreams; - - public void MarkBusy() - { - ActiveStreams++; - LastActivity = DateTime.UtcNow; - } - - public void MarkIdle() - { - ActiveStreams--; - LastActivity = DateTime.UtcNow; - } - - - public async ValueTask DisposeAsync() - { - if (!_alive) - { - return; - } - - _alive = false; - await Handle.DisposeAsync().ConfigureAwait(false); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionManagerActor.cs b/src/Servus.Akka/Transport/Quic/Client/QuicConnectionManagerActor.cs deleted file mode 100644 index 285a2ab1e..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionManagerActor.cs +++ /dev/null @@ -1,217 +0,0 @@ -using Akka.Actor; -using static Servus.Core.Servus; - -namespace Servus.Akka.Transport.Quic.Client; - -public sealed class QuicConnectionManagerActor : ReceiveActor, IWithTimers -{ - internal sealed record Acquire( - QuicTransportOptions Options, - TaskCompletionSource Tcs, - CancellationToken Token); - - internal sealed record Release(QuicConnectionLease Lease, bool CanReuse); - - private sealed record Established(QuicConnectionLease Lease, Acquire Original); - - private sealed record EstablishFailed(Exception Ex, Acquire Original); - - internal sealed class Evict - { - public static readonly Evict Instance = new(); - } - - private sealed class HostState(int maxConnections) - { - public readonly int MaxConnections = maxConnections; - public readonly List Leases = []; - public readonly Queue Pending = new(); - public int Establishing; - } - - private readonly Dictionary _hosts = new(); - private readonly IQuicConnectionFactory _factory; - private const string EvictTimerKey = "evict-idle"; - - public ITimerScheduler Timers { get; set; } = null!; - - internal static Task AcquireAsync( - IActorRef actor, QuicTransportOptions options, CancellationToken ct = default) - { - var tcs = new TaskCompletionSource(); - if (ct.CanBeCanceled) - { - ct.UnsafeRegister( - static (state, token) => ((TaskCompletionSource)state!).TrySetCanceled(token), - tcs); - } - - actor.Tell(new Acquire(options, tcs, ct)); - return tcs.Task; - } - - public QuicConnectionManagerActor() : this(new QuicConnectionFactory()) - { - } - - internal QuicConnectionManagerActor(IQuicConnectionFactory factory) - { - _factory = factory; - Receive(OnAcquire); - ReceiveAsync(OnRelease); - ReceiveAsync(OnEstablished); - Receive(OnFailed); - ReceiveAsync(_ => OnEvict()); - } - - protected override void PreStart() - { - Timers.StartPeriodicTimer(EvictTimerKey, Evict.Instance, - TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); - } - - private void OnAcquire(Acquire msg) - { - if (msg.Tcs.Task.IsCompleted) return; - - var host = GetOrCreateHost(msg.Options); - Tracing.For("Pool").Trace(this, "Acquire {0}:{1}", msg.Options.Host, msg.Options.Port); - - foreach (var lease in host.Leases) - { - if (!lease.CanAcceptStream() || lease.IsExpired(msg.Options.ConnectionLifetime)) - { - continue; - } - - lease.MarkBusy(); - if (msg.Tcs.TrySetResult(lease)) - { - Tracing.For("Pool").Debug(this, "Reused connection to {0}:{1}", msg.Options.Host, msg.Options.Port); - return; - } - - lease.MarkIdle(); - } - - if (host.Leases.Count + host.Establishing < host.MaxConnections) - { - Tracing.For("Pool").Debug(this, "Creating connection to {0}:{1}", msg.Options.Host, msg.Options.Port); - Establish(host, msg); - } - else - { - host.Pending.Enqueue(msg); - } - } - - private async Task OnRelease(Release msg) - { - Tracing.For("Pool").Trace(this, "Released {0}", msg.Lease); - msg.Lease.MarkIdle(); - - if (!msg.CanReuse || !msg.Lease.IsAlive()) - { - foreach (var host in _hosts.Values) - { - if (host.Leases.Remove(msg.Lease)) - { - break; - } - } - - if (msg.Lease.ActiveStreams == 0) - { - await msg.Lease.DisposeAsync(); - } - } - } - - private async Task OnEstablished(Established msg) - { - var host = GetOrCreateHost(msg.Original.Options); - host.Establishing--; - host.Leases.Add(msg.Lease); - msg.Lease.MarkBusy(); - Tracing.For("Pool").Debug(this, "Established to {0}:{1}", msg.Original.Options.Host, msg.Original.Options.Port); - - if (!msg.Original.Tcs.TrySetResult(msg.Lease)) - { - await OnRelease(new Release(msg.Lease, CanReuse: true)); - } - } - - private void OnFailed(EstablishFailed msg) - { - if (_hosts.TryGetValue(msg.Original.Options, out var host)) - { - host.Establishing--; - } - - Tracing.For("Pool").Warning(this, "Failed to {0}:{1}: {2}", msg.Original.Options.Host, msg.Original.Options.Port, msg.Ex.Message); - - if (msg.Ex is OperationCanceledException oce) - { - msg.Original.Tcs.TrySetCanceled(oce.CancellationToken); - } - else - { - msg.Original.Tcs.TrySetException(msg.Ex); - } - } - - private async Task OnEvict() - { - foreach (var host in _hosts.Values) - { - var toRemove = host.Leases - .Where(l => !l.IsAlive() || (l.ActiveStreams == 0 && l.IsExpired(TimeSpan.FromMinutes(10)))) - .ToList(); - - foreach (var lease in toRemove) - { - host.Leases.Remove(lease); - await lease.DisposeAsync(); - } - } - } - - protected override void PostStop() - { - Timers.CancelAll(); - foreach (var host in _hosts.Values) - { - while (host.Pending.TryDequeue(out var pending)) - { - pending.Tcs.TrySetException(new ObjectDisposedException(nameof(QuicConnectionManagerActor))); - } - - foreach (var lease in host.Leases) - { - _ = lease.DisposeAsync(); - } - } - - _hosts.Clear(); - } - - private HostState GetOrCreateHost(QuicTransportOptions options) - { - if (!_hosts.TryGetValue(options, out var state)) - { - state = new HostState(options.MaxConnectionsPerHost); - _hosts[options] = state; - } - - return state; - } - - private void Establish(HostState host, Acquire msg) - { - host.Establishing++; - _factory.EstablishAsync(msg.Options, msg.Token) - .PipeTo(Self, - success: lease => new Established(lease, msg), - failure: ex => new EstablishFailed(ex, msg)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionStage.cs b/src/Servus.Akka/Transport/Quic/Client/QuicConnectionStage.cs deleted file mode 100644 index adec12851..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/QuicConnectionStage.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Transport.Quic.Client; - -internal sealed class QuicConnectionStage : GraphStage, ITransportInbound>> -{ - private readonly IActorRef _connectionManager; - - private readonly Inlet> _in = new("QuicConnection.In"); - private readonly Outlet _out = new("QuicConnection.Out"); - - public override FlowShape, ITransportInbound> Shape { get; } - - public QuicConnectionStage(IActorRef connectionManager) - { - _connectionManager = connectionManager; - Shape = new FlowShape, ITransportInbound>(_in, _out); - } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new Logic(this); - - [ExcludeFromCodeCoverage] - private sealed class Logic : TimerGraphStageLogic, ITransportOperations - { - private readonly QuicConnectionStage _stage; - private readonly Queue _pendingReads = new(); - private QuicTransportStateMachine _sm = null!; - - public Logic(QuicConnectionStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage._in, - onPush: () => - { - var batch = Grab(stage._in); - foreach (var item in batch) - { - _sm.HandlePush(item); - } - }, - onUpstreamFinish: () => _sm.HandleUpstreamFinish()); - - SetHandler(stage._out, - onPull: () => - { - if (_pendingReads.TryDequeue(out var item)) - { - Push(_stage._out, item); - } - }, - onDownstreamFinish: _ => - { - _sm.HandleDownstreamFinish(); - CompleteStage(); - }); - } - - public override void PreStart() - { - var stageActor = GetStageActor(OnReceive); - _sm = new QuicTransportStateMachine(this, _stage._connectionManager, stageActor.Ref); - Pull(_stage._in); - } - - private void OnReceive((IActorRef sender, object message) args) - { - if (args.message is IQuicTransportEvent evt) - { - _sm.Dispatch(evt); - } - } - - protected override void OnTimer(object timerKey) - => _sm.OnTimer(timerKey as string); - - public override void PostStop() => _sm.PostStop(); - - void ITransportOperations.OnPushInbound(ITransportInbound item) - { - if (IsAvailable(_stage._out)) - { - Push(_stage._out, item); - } - else - { - _pendingReads.Enqueue(item); - } - } - - void ITransportOperations.OnSignalPullOutbound() - { - if (!IsClosed(_stage._in) && !HasBeenPulled(_stage._in)) - { - Pull(_stage._in); - } - } - - void ITransportOperations.OnCompleteStage() - => CompleteStage(); - - void ITransportOperations.OnScheduleTimer(string key, TimeSpan delay) - => ScheduleOnce(key, delay); - - void ITransportOperations.OnCancelTimer(string key) - => CancelTimer(key); - - ILoggingAdapter ITransportOperations.Log => Log; - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/Client/QuicTransportFactory.cs b/src/Servus.Akka/Transport/Quic/Client/QuicTransportFactory.cs deleted file mode 100644 index 1ec221284..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/QuicTransportFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Akka; -using Akka.Actor; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Transport.Quic.Client; - -public sealed class QuicTransportFactory(IActorRef connectionManager) : ITransportFactory -{ - public Flow Create() - { - var conflate = Flow.Create() - .ConflateWithSeed( - seed: item => new List { item }, - aggregate: (list, item) => - { - list.Add(item); - return list; - }); - - var stage = Flow.FromGraph(new QuicConnectionStage(connectionManager)); - - return conflate.Via(stage); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/Client/QuicTransportStateMachine.cs b/src/Servus.Akka/Transport/Quic/Client/QuicTransportStateMachine.cs deleted file mode 100644 index 181d70bbb..000000000 --- a/src/Servus.Akka/Transport/Quic/Client/QuicTransportStateMachine.cs +++ /dev/null @@ -1,500 +0,0 @@ -using System.Net; -using Akka.Actor; -using static Servus.Core.Servus; - -namespace Servus.Akka.Transport.Quic.Client; - -public sealed class QuicTransportStateMachine -{ - private const string ConnectTimerKey = "connect-timeout"; - private const string MigrationCheckTimerKey = "migration-check"; - private static readonly TimeSpan MigrationCheckInterval = TimeSpan.FromSeconds(5); - - private readonly ITransportOperations _ops; - private readonly IActorRef _connectionManager; - private readonly IActorRef _self; - - private QuicConnectionHandle? _connectionHandle; - private QuicConnectionLease? _connectionLease; - private int _connectionGen; - private ConnectTransport? _pendingConnect; - private bool _autoReconnect; - private bool _upstreamFinished; - private bool _isReconnecting; - private CancellationTokenSource? _acquireCts; - private EndPoint? _lastRemoteEndPoint; - - private readonly Dictionary _streams = new(); - private QuicPumpManager? _pumpManager; - - public QuicTransportStateMachine( - ITransportOperations ops, - IActorRef connectionManager, - IActorRef self) - { - _ops = ops; - _connectionManager = connectionManager; - _self = self; - } - - internal void Dispatch(IQuicTransportEvent evt) - { - switch (evt) - { - case ConnectionLeaseAcquired e: - OnConnectionLeaseAcquired(e.Lease); - break; - case StreamLeaseAcquired e: - OnStreamLeaseAcquired(e.Handle, e.StreamId); - break; - case AcquisitionFailed e: - OnAcquisitionFailed(e.Error); - break; - case InboundData e: - if (e.Gen == _connectionGen) - { - _ops.OnPushInbound(new MultiplexedData(e.Buffer, StreamTarget.FromId(e.StreamId))); - } - else - { - e.Buffer.Dispose(); - } - - break; - case InboundStreamAccepted e: - OnInboundStreamAccepted(e.Stream, e.StreamId); - break; - case InboundComplete e: - if (e.Gen == _connectionGen) - { - OnInboundComplete(e.Reason, e.StreamId); - } - - break; - case InboundPumpFailed e: - if (IsConnectionLevelError(e.Error)) - { - HandleConnectionFailure(DisconnectReason.Error); - } - else - { - OnInboundComplete(DisconnectReason.Error, e.StreamId); - } - - break; - case OutboundWriteDone: - _ops.OnSignalPullOutbound(); - break; - case OutboundWriteFailed e: - OnOutboundWriteFailed(e.Error); - break; - case MigrationDetected e: - _ops.OnPushInbound(new ConnectionMigrationDetected(e.OldEndPoint, e.NewEndPoint)); - break; - } - } - - public void HandlePush(ITransportOutbound item) - { - switch (item) - { - case ConnectTransport connect: - HandleConnectTransport(connect); - break; - case OpenStream open: - HandleOpenStream(open.StreamId, open.Direction); - break; - case MultiplexedData data: - HandleMultiplexedData(data); - break; - case CompleteWrites cw: - HandleCompleteWrites(cw.StreamId); - break; - case ResetStream rs: - HandleResetStream(rs.StreamId, rs.ErrorCode); - break; - case DisconnectTransport: - CleanupTransport(); - _ops.OnSignalPullOutbound(); - break; - } - } - - public void HandleUpstreamFinish() - { - _upstreamFinished = true; - if (_connectionHandle is null) - { - _ops.OnCompleteStage(); - return; - } - - _pumpManager?.StopAll(); - _ops.OnCompleteStage(); - } - - public void HandleDownstreamFinish() - { - CleanupTransport(); - } - - public void OnTimer(string? timerKey) - { - if (timerKey == MigrationCheckTimerKey) - { - CheckForConnectionMigration(); - _ops.OnScheduleTimer(MigrationCheckTimerKey, MigrationCheckInterval); - return; - } - - if (timerKey != ConnectTimerKey || _pendingConnect is null) - { - return; - } - - _pendingConnect = null; - - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Timeout)); - _ops.OnSignalPullOutbound(); - } - - public void PostStop() - { - _ops.OnCancelTimer(ConnectTimerKey); - _ops.OnCancelTimer(MigrationCheckTimerKey); - CleanupTransport(); - } - - private void HandleConnectTransport(ConnectTransport connect) - { - if (connect.Options is QuicTransportOptions quicOpts) - { - _autoReconnect = quicOpts.AutoReconnect; - } - - if (_connectionLease is not null) - { - _isReconnecting = true; - } - - CleanupTransport(); - _pendingConnect = connect; - AcquireConnection(connect); - _ops.OnSignalPullOutbound(); - } - - private void HandleOpenStream(StreamTarget streamId, StreamDirection direction) - { - if (_connectionHandle is null) - { - _ops.OnSignalPullOutbound(); - return; - } - - var state = new QuicStreamState(direction); - _streams[streamId] = state; - - var sid = streamId.Value; - _connectionHandle.OpenStreamAsync(direction) - .PipeTo(_self, - success: result => new StreamLeaseAcquired(new StreamHandle(result.Stream), sid), - failure: ex => new AcquisitionFailed(ex)); - - _ops.OnSignalPullOutbound(); - } - - private void HandleMultiplexedData(MultiplexedData data) - { - if (_streams.TryGetValue(data.StreamId, out var state)) - - { - state.Write(data.Buffer); - } - else - { - data.Buffer.Dispose(); - } - - _ops.OnSignalPullOutbound(); - } - - private void HandleCompleteWrites(StreamTarget streamId) - { - if (_streams.TryGetValue(streamId, out var state)) - { - state.CompleteWrites(); - if (state.Phase == StreamPhase.Closed) - { - _streams.Remove(streamId); - _ = state.DisposeAsync(); - } - } - - _ops.OnSignalPullOutbound(); - } - - private void HandleResetStream(StreamTarget streamId, long errorCode) - { - if (_streams.Remove(streamId, out var state)) - { - state.Abort(errorCode); - _ = state.DisposeAsync(); - _ops.OnPushInbound(new StreamClosed(streamId, DisconnectReason.Error)); - } - - _ops.OnSignalPullOutbound(); - } - - private void OnConnectionLeaseAcquired(QuicConnectionLease lease) - { - _ops.OnCancelTimer(ConnectTimerKey); - _pendingConnect = null; - _connectionGen++; - _connectionLease = lease; - _connectionHandle = lease.Handle; - _lastRemoteEndPoint = _connectionHandle.RemoteEndPoint(); - _ops.OnScheduleTimer(MigrationCheckTimerKey, MigrationCheckInterval); - - _pumpManager = new QuicPumpManager(_self); - _pumpManager.StartAcceptLoop(_connectionHandle); - Tracing.For("Connection").Debug(this, "QUIC transport ready"); - - if (_isReconnecting) - { - _isReconnecting = false; - } - - var info = new ConnectionInfo( - _connectionHandle.LocalEndPoint()!, - _connectionHandle.RemoteEndPoint()!, - TransportProtocol.Quic); - _ops.OnPushInbound(new TransportConnected(info)); - } - - private void OnStreamLeaseAcquired(StreamHandle handle, long rawStreamId) - { - var streamId = StreamTarget.FromId(rawStreamId); - if (!_streams.TryGetValue(streamId, out var state)) - { - _ = handle.DisposeAsync(); - return; - } - - state.AttachHandle(handle); - if (state.Direction == StreamDirection.Bidirectional) - { - _pumpManager?.StartInboundPump(handle, rawStreamId, _connectionGen); - } - - _ops.OnPushInbound(new StreamOpened(streamId, state.Direction)); - } - - private void OnInboundStreamAccepted(Stream stream, long rawStreamId) - { - var streamId = StreamTarget.FromId(rawStreamId); - var handle = new StreamHandle(stream); - var direction = (rawStreamId & 0x02) != 0 - ? StreamDirection.Unidirectional - : StreamDirection.Bidirectional; - var state = new QuicStreamState(direction); - state.AttachHandle(handle); - _streams[streamId] = state; - - _pumpManager?.StartInboundPump(handle, rawStreamId, _connectionGen); - _ops.OnPushInbound(new ServerStreamAccepted(streamId, direction)); - } - - private void OnInboundComplete(DisconnectReason reason, long rawStreamId) - { - var streamId = StreamTarget.FromId(rawStreamId); - if (!_streams.TryGetValue(streamId, out var state)) - { - return; - } - - if (reason == DisconnectReason.Graceful) - { - state.OnReadCompleted(); - - if (state.Phase == StreamPhase.Closed) - { - _streams.Remove(streamId); - _ = state.DisposeAsync(); - } - - _ops.OnPushInbound(new StreamReadCompleted(streamId)); - } - else - { - _streams.Remove(streamId); - _ = state.DisposeAsync(); - _ops.OnPushInbound(new StreamClosed(streamId, reason)); - } - } - - private void OnOutboundWriteFailed(Exception ex) - { - Tracing.For("Connection").Warning(this, "QUIC write failed: {0}", ex.Message); - HandleConnectionFailure(DisconnectReason.Error); - } - - private void OnAcquisitionFailed(Exception ex) - { - if (ex is OperationCanceledException) - { - return; - } - - _ops.OnCancelTimer(ConnectTimerKey); - Tracing.For("Connection").Warning(this, "QUIC acquisition failed: {0}", ex.Message); - - if (_pendingConnect is not null) - { - _pendingConnect = null; - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Error)); - _ops.OnSignalPullOutbound(); - return; - } - - HandleConnectionFailure(DisconnectReason.Error); - } - - private void HandleConnectionFailure(DisconnectReason reason) - { - Tracing.For("Connection").Debug(this, "QUIC disconnected: {0}", reason); - - if (_autoReconnect && !_upstreamFinished) - { - foreach (var (_, state) in _streams) - { - _ = state.DisposeAsync(); - } - - _streams.Clear(); - - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Transient)); - _isReconnecting = true; - _pumpManager?.StopAll(); - ReturnConnectionToPool(false); - _connectionHandle = null; - _connectionLease = null; - _ops.OnSignalPullOutbound(); - return; - } - - foreach (var (target, state) in _streams) - { - _ops.OnPushInbound(new StreamClosed(target, reason)); - _ = state.DisposeAsync(); - } - - _streams.Clear(); - - _ops.OnPushInbound(new TransportDisconnected(reason)); - _pumpManager?.StopAll(); - ReturnConnectionToPool(false); - _connectionHandle = null; - _connectionLease = null; - - if (_upstreamFinished) - { - _ops.OnCompleteStage(); - } - else - { - _ops.OnSignalPullOutbound(); - } - } - - private void CheckForConnectionMigration() - { - var currentRemote = _connectionHandle?.RemoteEndPoint(); - if (currentRemote is null || _lastRemoteEndPoint is null) - { - return; - } - - if (!currentRemote.Equals(_lastRemoteEndPoint)) - { - var old = _lastRemoteEndPoint; - _lastRemoteEndPoint = currentRemote; - _ops.OnPushInbound(new ConnectionMigrationDetected(old, currentRemote)); - } - } - - private void AcquireConnection(ConnectTransport connect) - { - _acquireCts?.Cancel(); - _acquireCts?.Dispose(); - _acquireCts = new CancellationTokenSource(); - - if (connect.Options is QuicTransportOptions quicOpts) - { - QuicConnectionManagerActor.AcquireAsync(_connectionManager, quicOpts, _acquireCts.Token) - .PipeTo(_self, - success: lease => new ConnectionLeaseAcquired(lease), - failure: ex => new AcquisitionFailed(ex)); - } - - var timeout = connect.Options.ConnectTimeout; - if (timeout <= TimeSpan.Zero) - { - timeout = TimeSpan.FromSeconds(10); - } - - _ops.OnScheduleTimer(ConnectTimerKey, timeout); - } - - private void ReturnConnectionToPool(bool canReuse) - { - if (_connectionLease is null) - { - return; - } - - var lease = _connectionLease; - _connectionLease = null; - - _connectionManager.Tell(new QuicConnectionManagerActor.Release(lease, canReuse)); - - if (!canReuse) - { - _ = lease.DisposeAsync(); - } - } - - private void CleanupTransport() - { - _connectionGen++; - _pumpManager?.StopAll(); - - _acquireCts?.Cancel(); - _acquireCts?.Dispose(); - _acquireCts = null; - - foreach (var (_, state) in _streams) - { - _ = state.DisposeAsync(); - } - - _streams.Clear(); - - ReturnConnectionToPool(false); - _connectionHandle = null; - _connectionLease = null; - } - private static bool IsConnectionLevelError(Exception ex) - { - if (ex is System.Net.Quic.QuicException qe) - { - return qe.QuicError is System.Net.Quic.QuicError.ConnectionAborted - or System.Net.Quic.QuicError.ConnectionIdle - or System.Net.Quic.QuicError.ConnectionRefused - or System.Net.Quic.QuicError.ConnectionTimeout; - } - - return ex is ObjectDisposedException; - } -} - -#pragma warning restore CA1416 \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerFactory.cs b/src/Servus.Akka/Transport/Quic/Listener/QuicListenerFactory.cs deleted file mode 100644 index 11807d675..000000000 --- a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Akka; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Transport.Quic.Listener; - -public sealed class QuicListenerFactory : IListenerFactory -{ - public Source, Task> Bind(ListenerOptions options) - { - if (options is not QuicListenerOptions quicOptions) - { - throw new ArgumentException( - $"Expected {nameof(QuicListenerOptions)} but got {options.GetType().Name}", - nameof(options)); - } - - return Source.FromGraph(new QuicListenerStage(quicOptions)); - } -} diff --git a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerStage.cs b/src/Servus.Akka/Transport/Quic/Listener/QuicListenerStage.cs deleted file mode 100644 index 170f5f42a..000000000 --- a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerStage.cs +++ /dev/null @@ -1,233 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net; -using System.Net.Quic; -using System.Net.Security; -using Akka; -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.Streams.Stage; - -namespace Servus.Akka.Transport.Quic.Listener; - -internal sealed record QuicConnectionAccepted(QuicConnection Connection); - -internal sealed record QuicAcceptFailed(Exception Error); - -internal sealed record QuicListenerBound(QuicListener Listener); - -internal sealed class QuicListenerStage - : GraphStageWithMaterializedValue>, Task> -{ - private readonly QuicListenerOptions _options; - - private readonly Outlet> _out = - new("QuicListener.Out"); - - public override SourceShape> Shape { get; } - - public QuicListenerStage(QuicListenerOptions options) - { - _options = options; - Shape = new SourceShape>(_out); - } - - public override ILogicAndMaterializedValue CreateLogicAndMaterializedValue( - Attributes inheritedAttributes) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - return new LogicAndMaterializedValue(new Logic(this, tcs), tcs.Task); - } - - [ExcludeFromCodeCoverage] - private sealed class Logic : GraphStageLogic - { - private readonly QuicListenerStage _stage; - private readonly TaskCompletionSource _boundSignal; - private readonly Queue> _pendingConnections = new(); - private QuicListener? _listener; - private IActorRef _self = null!; - private CancellationTokenSource? _cts; - - public Logic(QuicListenerStage stage, TaskCompletionSource boundSignal) : base(stage.Shape) - { - _stage = stage; - _boundSignal = boundSignal; - - SetHandler(stage._out, onPull: () => TryPush()); - } - - public override void PreStart() - { - var stageActor = GetStageActor(OnReceive); - _self = stageActor.Ref; - _cts = new CancellationTokenSource(); - - BindAsync(_cts.Token) - .PipeTo(_self, - success: listener => new QuicListenerBound(listener), - failure: ex => new QuicAcceptFailed(ex)); - } - - public override void PostStop() - { - _cts?.Cancel(); - _cts?.Dispose(); - _cts = null; - - if (_listener is not null) - { - _ = _listener.DisposeAsync(); - _listener = null; - } - - while (_pendingConnections.TryDequeue(out _)) - { - } - } - - private async Task BindAsync(CancellationToken ct) - { - var opts = _stage._options; - var address = IPAddress.TryParse(opts.Host, out var ip) - ? ip - : IPAddress.Any; - - var nativeListenerOptions = new System.Net.Quic.QuicListenerOptions - { - ListenEndPoint = new IPEndPoint(address, opts.Port), - ApplicationProtocols = opts.ApplicationProtocols, - ConnectionOptionsCallback = (_, _, _) => - { - var serverOptions = new QuicServerConnectionOptions - { - DefaultStreamErrorCode = 0x0100, - DefaultCloseErrorCode = 0x0100, - MaxInboundBidirectionalStreams = opts.MaxInboundBidirectionalStreams, - MaxInboundUnidirectionalStreams = opts.MaxInboundUnidirectionalStreams, - IdleTimeout = opts.IdleTimeout, - ServerAuthenticationOptions = new SslServerAuthenticationOptions - { - ServerCertificate = opts.ServerCertificate, - ApplicationProtocols = opts.ApplicationProtocols, - EnabledSslProtocols = opts.EnabledSslProtocols, - RemoteCertificateValidationCallback = opts.ClientCertificateValidationCallback - } - }; - return ValueTask.FromResult(serverOptions); - } - }; - - return await QuicListener.ListenAsync(nativeListenerOptions, ct).ConfigureAwait(false); - } - - private static async Task AcceptLoopAsync(QuicListener listener, IActorRef self, CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try - { - var connection = await listener.AcceptConnectionAsync(ct).ConfigureAwait(false); - self.Tell(new QuicConnectionAccepted(connection)); - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - self.Tell(new QuicAcceptFailed(ex)); - return; - } - } - } - - private void OnReceive((IActorRef sender, object message) args) - { - switch (args.message) - { - case QuicListenerBound bound: - _listener = bound.Listener; - _boundSignal.TrySetResult(); - _ = AcceptLoopAsync(_listener, _self, _cts!.Token); - break; - case QuicConnectionAccepted accepted: - OnConnectionAccepted(accepted.Connection); - break; - case QuicAcceptFailed failed: - OnAcceptError(failed.Error); - break; - } - } - - private void OnConnectionAccepted(QuicConnection connection) - { - SecurityInfo? security = connection.NegotiatedApplicationProtocol.Protocol.Length > 0 - ? new SecurityInfo( - System.Security.Authentication.SslProtocols.None, - connection.NegotiatedApplicationProtocol) - : null; - - var connectionInfo = new ConnectionInfo( - connection.LocalEndPoint, - connection.RemoteEndPoint, - TransportProtocol.Quic, - security); - - var handle = new QuicConnectionHandle( - openStream: async (direction, token) => - { - var streamType = direction == StreamDirection.Bidirectional - ? QuicStreamType.Bidirectional - : QuicStreamType.Unidirectional; - var stream = await connection.OpenOutboundStreamAsync(streamType, token).ConfigureAwait(false); - return (stream, stream.Id); - }, - acceptInboundStream: async token => - { - try - { - var stream = await connection.AcceptInboundStreamAsync(token).ConfigureAwait(false); - return (stream, stream.Id); - } - catch (OperationCanceledException) - { - return null; - } - catch - { - return null; - } - }, - getLocalEndPoint: () => connection.LocalEndPoint, - getRemoteEndPoint: () => connection.RemoteEndPoint, - dispose: () => connection.DisposeAsync()); - - var connectionFlow = Flow.FromGraph( - new QuicServerConnectionStage(handle, connectionInfo)); - - _pendingConnections.Enqueue(connectionFlow); - TryPush(); - } - - private void TryPush() - { - if (IsAvailable(_stage._out) && _pendingConnections.TryDequeue(out var flow)) - { - Push(_stage._out, flow); - } - } - - private void OnAcceptError(Exception ex) - { - if (ex is ObjectDisposedException or OperationCanceledException) - { - return; - } - - Log.Error(ex, "QUIC listener accept failed"); - FailStage(ex); - } - } -} diff --git a/src/Servus.Akka/Transport/Quic/Listener/QuicServerConnectionStage.cs b/src/Servus.Akka/Transport/Quic/Listener/QuicServerConnectionStage.cs deleted file mode 100644 index 5523ce9c0..000000000 --- a/src/Servus.Akka/Transport/Quic/Listener/QuicServerConnectionStage.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Transport.Quic.Listener; - -internal sealed class QuicServerConnectionStage : GraphStage> -{ - private readonly QuicConnectionHandle _connectionHandle; - private readonly ConnectionInfo _connectionInfo; - - private readonly Inlet _in = new("QuicServerConnection.In"); - private readonly Outlet _out = new("QuicServerConnection.Out"); - - public override FlowShape Shape { get; } - - public QuicServerConnectionStage(QuicConnectionHandle connectionHandle, ConnectionInfo connectionInfo) - { - _connectionHandle = connectionHandle; - _connectionInfo = connectionInfo; - Shape = new FlowShape(_in, _out); - } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new Logic(this); - - [ExcludeFromCodeCoverage] - private sealed class Logic : TimerGraphStageLogic, ITransportOperations - { - private readonly QuicServerConnectionStage _stage; - private readonly Queue _pendingReads = new(); - private QuicServerStateMachine _sm = null!; - - public Logic(QuicServerConnectionStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage._in, - onPush: () => _sm.HandlePush(Grab(stage._in)), - onUpstreamFinish: () => _sm.HandleUpstreamFinish()); - - SetHandler(stage._out, - onPull: () => - { - if (_pendingReads.TryDequeue(out var item)) - { - Push(_stage._out, item); - } - }, - onDownstreamFinish: _ => - { - _sm.HandleDownstreamFinish(); - CompleteStage(); - }); - } - - public override void PreStart() - { - var stageActor = GetStageActor(OnReceive); - _sm = new QuicServerStateMachine( - this, - stageActor.Ref, - _stage._connectionHandle, - _stage._connectionInfo); - _sm.Start(); - Pull(_stage._in); - } - - private void OnReceive((IActorRef sender, object message) args) - { - if (args.message is IQuicTransportEvent evt) - { - _sm.Dispatch(evt); - } - } - - protected override void OnTimer(object timerKey) => _sm.OnTimer(timerKey as string); - - public override void PostStop() => _sm.PostStop(); - - void ITransportOperations.OnPushInbound(ITransportInbound item) - { - if (IsAvailable(_stage._out)) - { - Push(_stage._out, item); - } - else - { - _pendingReads.Enqueue(item); - } - } - - void ITransportOperations.OnSignalPullOutbound() - { - if (!IsClosed(_stage._in) && !HasBeenPulled(_stage._in)) - { - Pull(_stage._in); - } - } - - void ITransportOperations.OnCompleteStage() => CompleteStage(); - - void ITransportOperations.OnScheduleTimer(string key, TimeSpan delay) - => ScheduleOnce(key, delay); - - void ITransportOperations.OnCancelTimer(string key) => CancelTimer(key); - - ILoggingAdapter ITransportOperations.Log => Log; - } -} diff --git a/src/Servus.Akka/Transport/Quic/Listener/QuicServerStateMachine.cs b/src/Servus.Akka/Transport/Quic/Listener/QuicServerStateMachine.cs deleted file mode 100644 index 746f480c5..000000000 --- a/src/Servus.Akka/Transport/Quic/Listener/QuicServerStateMachine.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System.Net; -using Akka.Actor; - -namespace Servus.Akka.Transport.Quic.Listener; - -internal sealed class QuicServerStateMachine -{ - private const string MigrationCheckTimerKey = "migration-check"; - private static readonly TimeSpan MigrationCheckInterval = TimeSpan.FromSeconds(5); - - private readonly ITransportOperations _ops; - private readonly IActorRef _self; - private readonly QuicConnectionHandle _connectionHandle; - private readonly ConnectionInfo _connectionInfo; - - private int _connectionGen; - private bool _upstreamFinished; - private EndPoint? _lastRemoteEndPoint; - - private readonly Dictionary _streams = new(); - private QuicPumpManager? _pumpManager; - - public QuicServerStateMachine( - ITransportOperations ops, - IActorRef self, - QuicConnectionHandle connectionHandle, - ConnectionInfo connectionInfo) - { - _ops = ops; - _self = self; - _connectionHandle = connectionHandle; - _connectionInfo = connectionInfo; - } - - public void Start() - { - _connectionGen++; - _lastRemoteEndPoint = _connectionHandle.RemoteEndPoint(); - _ops.OnScheduleTimer(MigrationCheckTimerKey, MigrationCheckInterval); - - _pumpManager = new QuicPumpManager(_self); - _pumpManager.StartAcceptLoop(_connectionHandle); - - _ops.OnPushInbound(new TransportConnected(_connectionInfo)); - } - - internal void Dispatch(IQuicTransportEvent evt) - { - switch (evt) - { - case InboundData e: - if (e.Gen == _connectionGen) - { - _ops.OnPushInbound(new MultiplexedData(e.Buffer, StreamTarget.FromId(e.StreamId))); - } - else - { - e.Buffer.Dispose(); - } - break; - case InboundStreamAccepted e: - OnInboundStreamAccepted(e.Stream, e.StreamId); - break; - case StreamLeaseAcquired e: - OnStreamLeaseAcquired(e.Handle, e.StreamId); - break; - case InboundComplete e: - if (e.Gen == _connectionGen) - { - OnInboundComplete(e.Reason, e.StreamId); - } - break; - case InboundPumpFailed e: - OnInboundComplete(DisconnectReason.Error, e.StreamId); - break; - case OutboundWriteDone: - _ops.OnSignalPullOutbound(); - break; - case OutboundWriteFailed: - HandleConnectionFailure(DisconnectReason.Error); - break; - case MigrationDetected e: - _ops.OnPushInbound(new ConnectionMigrationDetected(e.OldEndPoint, e.NewEndPoint)); - break; - } - } - - public void HandlePush(ITransportOutbound item) - { - switch (item) - { - case OpenStream open: - HandleOpenStream(open.StreamId, open.Direction); - break; - case MultiplexedData data: - HandleMultiplexedData(data); - break; - case CompleteWrites cw: - HandleCompleteWrites(cw.StreamId); - break; - case ResetStream rs: - HandleResetStream(rs.StreamId, rs.ErrorCode); - break; - case DisconnectTransport: - Cleanup(); - _ops.OnCompleteStage(); - break; - } - } - - public void HandleUpstreamFinish() - { - _upstreamFinished = true; - _pumpManager?.StopAll(); - _ops.OnCompleteStage(); - } - - public void HandleDownstreamFinish() - { - Cleanup(); - } - - public void OnTimer(string? timerKey) - { - if (timerKey == MigrationCheckTimerKey) - { - CheckForConnectionMigration(); - _ops.OnScheduleTimer(MigrationCheckTimerKey, MigrationCheckInterval); - } - } - - public void PostStop() - { - _ops.OnCancelTimer(MigrationCheckTimerKey); - Cleanup(); - } - - private void HandleOpenStream(StreamTarget streamId, StreamDirection direction) - { - var state = new QuicStreamState(direction); - _streams[streamId] = state; - - var sid = streamId.Value; - _connectionHandle.OpenStreamAsync(direction) - .PipeTo(_self, - success: result => new StreamLeaseAcquired(new StreamHandle(result.Stream), sid), - failure: ex => new AcquisitionFailed(ex)); - - _ops.OnSignalPullOutbound(); - } - - private void HandleMultiplexedData(MultiplexedData data) - { - if (_streams.TryGetValue(data.StreamId, out var state)) - { - state.Write(data.Buffer); - } - else - { - data.Buffer.Dispose(); - } - - _ops.OnSignalPullOutbound(); - } - - private void HandleCompleteWrites(StreamTarget streamId) - { - if (_streams.TryGetValue(streamId, out var state)) - { - state.CompleteWrites(); - if (state.Phase == StreamPhase.Closed) - { - _streams.Remove(streamId); - _ = state.DisposeAsync(); - } - } - - _ops.OnSignalPullOutbound(); - } - - private void HandleResetStream(StreamTarget streamId, long errorCode) - { - if (_streams.Remove(streamId, out var state)) - { - state.Abort(errorCode); - _ = state.DisposeAsync(); - _ops.OnPushInbound(new StreamClosed(streamId, DisconnectReason.Error)); - } - - _ops.OnSignalPullOutbound(); - } - - private void OnStreamLeaseAcquired(StreamHandle handle, long rawStreamId) - { - var streamId = StreamTarget.FromId(rawStreamId); - if (!_streams.TryGetValue(streamId, out var state)) - { - _ = handle.DisposeAsync(); - return; - } - - state.AttachHandle(handle); - if (state.Direction == StreamDirection.Bidirectional) - { - _pumpManager?.StartInboundPump(handle, rawStreamId, _connectionGen); - } - - _ops.OnPushInbound(new StreamOpened(streamId, state.Direction)); - } - - private void OnInboundStreamAccepted(Stream stream, long rawStreamId) - { - var streamId = StreamTarget.FromId(rawStreamId); - var handle = new StreamHandle(stream); - var direction = (rawStreamId & 0x02) != 0 - ? StreamDirection.Unidirectional - : StreamDirection.Bidirectional; - var state = new QuicStreamState(direction); - state.AttachHandle(handle); - _streams[streamId] = state; - - _pumpManager?.StartInboundPump(handle, rawStreamId, _connectionGen); - _ops.OnPushInbound(new ServerStreamAccepted(streamId, direction)); - } - - private void OnInboundComplete(DisconnectReason reason, long rawStreamId) - { - var streamId = StreamTarget.FromId(rawStreamId); - if (!_streams.TryGetValue(streamId, out var state)) - { - return; - } - - if (reason == DisconnectReason.Graceful) - { - state.OnReadCompleted(); - - if (state.Phase == StreamPhase.Closed) - { - _streams.Remove(streamId); - _ = state.DisposeAsync(); - } - - _ops.OnPushInbound(new StreamReadCompleted(streamId)); - } - else - { - _streams.Remove(streamId); - _ = state.DisposeAsync(); - _ops.OnPushInbound(new StreamClosed(streamId, reason)); - } - } - - private void HandleConnectionFailure(DisconnectReason reason) - { - foreach (var (target, state) in _streams) - { - _ops.OnPushInbound(new StreamClosed(target, reason)); - _ = state.DisposeAsync(); - } - - _streams.Clear(); - - _ops.OnPushInbound(new TransportDisconnected(reason)); - _pumpManager?.StopAll(); - - if (_upstreamFinished) - { - _ops.OnCompleteStage(); - } - else - { - _ops.OnSignalPullOutbound(); - } - } - - private void CheckForConnectionMigration() - { - var currentRemote = _connectionHandle.RemoteEndPoint(); - if (currentRemote is null || _lastRemoteEndPoint is null) - { - return; - } - - if (!currentRemote.Equals(_lastRemoteEndPoint)) - { - var old = _lastRemoteEndPoint; - _lastRemoteEndPoint = currentRemote; - _ops.OnPushInbound(new ConnectionMigrationDetected(old, currentRemote)); - } - } - - private void Cleanup() - { - _connectionGen++; - _pumpManager?.StopAll(); - _pumpManager = null; - - foreach (var (_, state) in _streams) - { - _ = state.DisposeAsync(); - } - - _streams.Clear(); - - _ = _connectionHandle.DisposeAsync(); - } -} diff --git a/src/Servus.Akka/Transport/Quic/QuicConnectionHandle.cs b/src/Servus.Akka/Transport/Quic/QuicConnectionHandle.cs deleted file mode 100644 index 16fa7c665..000000000 --- a/src/Servus.Akka/Transport/Quic/QuicConnectionHandle.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Transport.Quic; - -internal sealed class QuicConnectionHandle : IAsyncDisposable -{ - private readonly Func> _openStream; - private readonly Func> _acceptInboundStream; - private readonly Func _dispose; - private readonly Func _getLocalEndPoint; - private readonly Func _getRemoteEndPoint; - - internal QuicConnectionHandle( - Func> openStream, - Func> acceptInboundStream, - Func getLocalEndPoint, - Func getRemoteEndPoint, - Func dispose) - { - _openStream = openStream; - _acceptInboundStream = acceptInboundStream; - _getLocalEndPoint = getLocalEndPoint; - _getRemoteEndPoint = getRemoteEndPoint; - _dispose = dispose; - } - - public Task<(Stream Stream, long StreamId)> OpenStreamAsync( - StreamDirection direction, CancellationToken ct = default) - => _openStream(direction, ct); - - public Task<(Stream Stream, long StreamId)?> AcceptInboundStreamAsync( - CancellationToken ct = default) - => _acceptInboundStream(ct); - - public EndPoint? LocalEndPoint() => _getLocalEndPoint(); - - public EndPoint? RemoteEndPoint() => _getRemoteEndPoint(); - - public ValueTask DisposeAsync() => _dispose(); -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/QuicPumpManager.cs b/src/Servus.Akka/Transport/Quic/QuicPumpManager.cs deleted file mode 100644 index df2e0ac1c..000000000 --- a/src/Servus.Akka/Transport/Quic/QuicPumpManager.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Buffers; -using Akka.Actor; - -namespace Servus.Akka.Transport.Quic; - -internal sealed class QuicPumpManager -{ - private readonly IActorRef _self; - private CancellationTokenSource? _pumpsCts; - private CancellationTokenSource? _acceptCts; - - public QuicPumpManager(IActorRef self) - { - _self = self; - } - - public void StartInboundPump(StreamHandle handle, long streamId, int gen) - { - _pumpsCts ??= new CancellationTokenSource(); - _ = DirectStreamPumpAsync(handle, streamId, _pumpsCts.Token, _self, gen); - } - - public void StartAcceptLoop(QuicConnectionHandle connectionHandle) - { - _acceptCts?.Cancel(); - _acceptCts?.Dispose(); - _acceptCts = new CancellationTokenSource(); - _ = AcceptLoopAsync(connectionHandle, _self, _acceptCts.Token); - } - - public void StopAll() - { - _acceptCts?.Cancel(); - _acceptCts?.Dispose(); - _acceptCts = null; - - _pumpsCts?.Cancel(); - _pumpsCts?.Dispose(); - _pumpsCts = null; - } - - private static async Task AcceptLoopAsync( - QuicConnectionHandle handle, IActorRef self, CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - var result = await handle.AcceptInboundStreamAsync(ct).ConfigureAwait(false); - - if (ct.IsCancellationRequested) - { - if (result is not null) - { - await result.Value.Stream.DisposeAsync().ConfigureAwait(false); - } - - return; - } - - if (result is null) - { - continue; - } - - self.Tell(new InboundStreamAccepted(result.Value.Stream, result.Value.StreamId)); - } - } - - private static async Task DirectStreamPumpAsync(StreamHandle handle, long streamId, CancellationToken ct, - IActorRef self, int gen) - { - var closeReason = DisconnectReason.Graceful; - var pool = MemoryPool.Shared; - try - { - while (!ct.IsCancellationRequested) - { - var owner = pool.Rent(16384); - int bytesRead; - try - { - bytesRead = await handle.ReadAsync(owner.Memory, ct).ConfigureAwait(false); - } - catch - { - owner.Dispose(); - throw; - } - - if (bytesRead == 0) - { - owner.Dispose(); - break; - } - - var tb = TransportBuffer.Rent(bytesRead); - owner.Memory.Span[..bytesRead].CopyTo(tb.FullMemory.Span); - tb.Length = bytesRead; - owner.Dispose(); - - self.Tell(new InboundData(tb, streamId, gen)); - } - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - self.Tell(new InboundPumpFailed(ex, streamId)); - return; - } - - self.Tell(new InboundComplete(closeReason, gen, streamId)); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/QuicStreamState.cs b/src/Servus.Akka/Transport/Quic/QuicStreamState.cs deleted file mode 100644 index 1ad79cae5..000000000 --- a/src/Servus.Akka/Transport/Quic/QuicStreamState.cs +++ /dev/null @@ -1,122 +0,0 @@ -namespace Servus.Akka.Transport.Quic; - -internal enum StreamPhase -{ - Opening, - Active, - HalfClosedWrite, - HalfClosedRead, - Closed -} - -internal sealed class QuicStreamState -{ - private StreamHandle? _handle; - private Queue? _openingBuffer = new(); - - public QuicStreamState(StreamDirection direction) - { - Direction = direction; - Phase = StreamPhase.Opening; - } - - public StreamPhase Phase { get; private set; } - public StreamDirection Direction { get; } - public bool HasHandle => _handle is not null; - public int PendingWriteCount => _openingBuffer?.Count ?? 0; - public bool IsCompleteWritesDeferred { get; private set; } - - public void AttachHandle(StreamHandle handle) - { - _handle = handle; - - if (_openingBuffer is not null) - { - while (_openingBuffer.TryDequeue(out var buf)) - { - _handle.Write(buf); - } - - _openingBuffer = null; - } - - if (IsCompleteWritesDeferred) - { - IsCompleteWritesDeferred = false; - _handle.CompleteWrites(); - Phase = StreamPhase.HalfClosedWrite; - } - else - { - Phase = StreamPhase.Active; - } - } - - public void Write(TransportBuffer buffer) - { - if (_handle is null) - { - _openingBuffer?.Enqueue(buffer); - return; - } - - _handle.Write(buffer); - } - - public void CompleteWrites() - { - switch (Phase) - { - case StreamPhase.Opening: - IsCompleteWritesDeferred = true; - return; - case StreamPhase.Active: - _handle?.CompleteWrites(); - Phase = StreamPhase.HalfClosedWrite; - return; - case StreamPhase.HalfClosedRead: - _handle?.CompleteWrites(); - Phase = StreamPhase.Closed; - return; - } - } - - public void OnReadCompleted() - { - Phase = Phase switch - { - StreamPhase.Active => StreamPhase.HalfClosedRead, - StreamPhase.HalfClosedWrite => StreamPhase.Closed, - _ => Phase - }; - } - - public void Abort(long errorCode) - { - _handle?.Abort(errorCode); - Phase = StreamPhase.Closed; - } - - private void DisposePendingWrites() - { - if (_openingBuffer is null) - { - return; - } - - while (_openingBuffer.TryDequeue(out var orphan)) - { - orphan.Dispose(); - } - } - - public async ValueTask DisposeAsync() - { - DisposePendingWrites(); - if (_handle is not null) - { - await _handle.DisposeAsync().ConfigureAwait(false); - _handle = null; - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Quic/QuicTransportEvent.cs b/src/Servus.Akka/Transport/Quic/QuicTransportEvent.cs deleted file mode 100644 index a6429ce73..000000000 --- a/src/Servus.Akka/Transport/Quic/QuicTransportEvent.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Net; -using Servus.Akka.Transport.Quic.Client; - -namespace Servus.Akka.Transport.Quic; - -internal interface IQuicTransportEvent; - -internal readonly record struct ConnectionLeaseAcquired(QuicConnectionLease Lease) : IQuicTransportEvent; - -internal readonly record struct StreamLeaseAcquired(StreamHandle Handle, long StreamId) : IQuicTransportEvent; - -internal readonly record struct AcquisitionFailed(Exception Error) : IQuicTransportEvent; - -internal readonly record struct InboundData(TransportBuffer Buffer, long StreamId, int Gen) : IQuicTransportEvent; - -internal readonly record struct InboundStreamAccepted(Stream Stream, long StreamId) : IQuicTransportEvent; - -internal readonly record struct InboundComplete(DisconnectReason Reason, int Gen, long StreamId) : IQuicTransportEvent; - -internal readonly record struct InboundPumpFailed(Exception Error, long StreamId) : IQuicTransportEvent; - -internal readonly record struct OutboundWriteDone(long StreamId) : IQuicTransportEvent; - -internal readonly record struct OutboundWriteFailed(Exception Error, long StreamId) : IQuicTransportEvent; - -internal readonly record struct MigrationDetected(EndPoint OldEndPoint, EndPoint NewEndPoint) : IQuicTransportEvent; - - diff --git a/src/Servus.Akka/Transport/Quic/StreamHandle.cs b/src/Servus.Akka/Transport/Quic/StreamHandle.cs deleted file mode 100644 index 44b0bf411..000000000 --- a/src/Servus.Akka/Transport/Quic/StreamHandle.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace Servus.Akka.Transport.Quic; - -internal sealed class StreamHandle : IAsyncDisposable -{ - private readonly Stream _stream; - - internal StreamHandle(Stream stream) - { - _stream = stream; - } - - public void Write(TransportBuffer buffer) - { - var memory = buffer.Memory; - _stream.Write(memory.Span); - buffer.Dispose(); - } - - public ValueTask ReadAsync(Memory buffer, CancellationToken ct) - { - return _stream.ReadAsync(buffer, ct); - } - - public void CompleteWrites() - { - if (_stream is System.Net.Quic.QuicStream qs) - { - qs.CompleteWrites(); - } - } - - public void Abort(long errorCode) - { - if (_stream is System.Net.Quic.QuicStream qs) - { - qs.Abort(System.Net.Quic.QuicAbortDirection.Both, errorCode); - } - } - - public ValueTask DisposeAsync() => _stream.DisposeAsync(); -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/QuicListenerOptions.cs b/src/Servus.Akka/Transport/QuicListenerOptions.cs deleted file mode 100644 index 2889b4792..000000000 --- a/src/Servus.Akka/Transport/QuicListenerOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; - -namespace Servus.Akka.Transport; - -public sealed record QuicListenerOptions : ListenerOptions -{ - public int MaxInboundBidirectionalStreams { get; init; } = 100; - public int MaxInboundUnidirectionalStreams { get; init; } = 3; - public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(30); - public required X509Certificate2 ServerCertificate { get; init; } - public required List ApplicationProtocols { get; init; } - public SslProtocols EnabledSslProtocols { get; init; } = SslProtocols.None; - public RemoteCertificateValidationCallback? ClientCertificateValidationCallback { get; init; } -} diff --git a/src/Servus.Akka/Transport/QuicTransportOptions.cs b/src/Servus.Akka/Transport/QuicTransportOptions.cs deleted file mode 100644 index ca58a459e..000000000 --- a/src/Servus.Akka/Transport/QuicTransportOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; - -namespace Servus.Akka.Transport; - -public sealed record QuicTransportOptions : TransportOptions -{ - public string? TargetHost { get; init; } - public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(30); - public int MaxBidirectionalStreams { get; init; } = 100; - public int MaxUnidirectionalStreams { get; init; } = 3; - public bool AllowConnectionMigration { get; init; } = true; - public X509CertificateCollection? ClientCertificates { get; init; } - public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; init; } - public SslProtocols EnabledSslProtocols { get; init; } = SslProtocols.None; - public List? ApplicationProtocols { get; init; } - public bool AutoReconnect { get; init; } - public int MaxConnectionsPerHost { get; init; } = 1; - public TimeSpan ConnectionLifetime { get; init; } = TimeSpan.FromMinutes(10); -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/SecurityInfo.cs b/src/Servus.Akka/Transport/SecurityInfo.cs deleted file mode 100644 index 97a51d799..000000000 --- a/src/Servus.Akka/Transport/SecurityInfo.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Net.Security; -using System.Security.Authentication; - -namespace Servus.Akka.Transport; - -public sealed record SecurityInfo( - SslProtocols Protocol, - SslApplicationProtocol ApplicationProtocol, - TlsCipherSuite? NegotiatedCipherSuite = null, - string? HostName = null, - SslStream? SslStream = null, - bool AllowDelayedNegotiation = false); diff --git a/src/Servus.Akka/Transport/ServusExtensions.cs b/src/Servus.Akka/Transport/ServusExtensions.cs deleted file mode 100644 index bfeed0ca1..000000000 --- a/src/Servus.Akka/Transport/ServusExtensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; -using Servus.Core.Diagnostics; - -namespace Servus.Akka.Transport; - -internal static class ServusExtensions -{ - private static Histogram? _dnsLookupDuration; - private static Histogram? _socketConnectDuration; - - public static Histogram DnsLookupDuration(this ServusMetrics metrics) - { - return _dnsLookupDuration ??= metrics.Meter.CreateHistogram( - "dns.lookup.duration", - unit: "s", - description: "Duration of DNS lookups in seconds"); - } - - public static Histogram SocketConnectDuration(this ServusMetrics metrics) - { - return _socketConnectDuration ??= metrics.Meter.CreateHistogram( - "network.socket.connect.duration", - unit: "s", - description: "Duration of socket connect operations in seconds"); - } -} - -internal static class ServusTraceExtensions -{ - public static Activity? StartDnsLookup(this ServusTrace trace, string hostname) - { - if (!trace.Source.HasListeners()) - { - return null; - } - - var activity = trace.Source.StartActivity("dns.lookup", ActivityKind.Client); - activity?.SetTag("dns.question.name", hostname); - return activity; - } - - public static void SetDnsAnswers(this ServusTrace _, Activity activity, string[] answers) - { - activity.SetTag("dns.answers", string.Join(",", answers)); - activity.SetTag("dns.answer.count", answers.Length); - } - - public static Activity? StartSocketConnect(this ServusTrace trace, string address, int port, string transport, string networkType) - { - if (!trace.Source.HasListeners()) - { - return null; - } - - var activity = trace.Source.StartActivity("network.socket.connect", ActivityKind.Client); - if (activity is null) - { - return null; - } - - activity.SetTag("network.peer.address", address); - activity.SetTag("network.peer.port", port); - activity.SetTag("network.transport", transport); - activity.SetTag("network.type", networkType); - return activity; - } - - public static void SetError(this ServusTrace _, Activity activity, Exception exception) - { - activity.SetStatus(ActivityStatusCode.Error, exception.Message); - activity.SetTag("error.type", exception.GetType().FullName); - activity.SetTag("exception.message", exception.Message); - } -} diff --git a/src/Servus.Akka/Transport/StreamDirection.cs b/src/Servus.Akka/Transport/StreamDirection.cs deleted file mode 100644 index ad8e6f1e4..000000000 --- a/src/Servus.Akka/Transport/StreamDirection.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Servus.Akka.Transport; - -public enum StreamDirection -{ - Unidirectional, - Bidirectional -} diff --git a/src/Servus.Akka/Transport/StreamTarget.cs b/src/Servus.Akka/Transport/StreamTarget.cs deleted file mode 100644 index 0fa34b2e8..000000000 --- a/src/Servus.Akka/Transport/StreamTarget.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Servus.Akka.Transport; - -public readonly record struct StreamTarget(long Value) -{ - public static StreamTarget FromId(long id) => new(id); - - public override string ToString() => Value.ToString(); - - public static implicit operator StreamTarget(long value) => new(value); - public static implicit operator StreamTarget(int value) => new(value); - public static implicit operator long(StreamTarget target) => target.Value; -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Tcp/Client/AbruptCloseException.cs b/src/Servus.Akka/Transport/Tcp/Client/AbruptCloseException.cs deleted file mode 100644 index a69eb3f05..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/AbruptCloseException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Servus.Akka.Transport.Tcp.Client; - -internal sealed class AbruptCloseException() : Exception("Connection closed abruptly."); diff --git a/src/Servus.Akka/Transport/Tcp/Client/ClientByteMover.cs b/src/Servus.Akka/Transport/Tcp/Client/ClientByteMover.cs deleted file mode 100644 index f9829d42b..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/ClientByteMover.cs +++ /dev/null @@ -1,267 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; -using System.Threading.Channels; - -namespace Servus.Akka.Transport.Tcp.Client; - -internal static class ClientByteMover -{ - public static Task MoveStreamToChannel(ClientState state, Action onClose, CancellationToken ct) - { - var fillTask = FillPipeFromStream(state.Stream, state.InboundPipe.Writer, ct); - var drainTask = DrainPipeToChannel(state.InboundPipe.Reader, state.InboundWriter, onClose, ct); - return Task.WhenAll(fillTask, drainTask); - } - - public static Task MoveChannelToStream(ClientState state, Action onClose, CancellationToken ct) - { - var fillTask = FillPipeFromChannel(state.OutboundReader, state.OutboundPipe.Writer, ct); - var drainTask = DrainPipeToStream(state.OutboundPipe.Reader, state.Stream, state.OnWritesComplete, onClose, ct); - return Task.WhenAll(fillTask, drainTask); - } - - private static async Task FillPipeFromStream(Stream stream, PipeWriter writer, CancellationToken ct) - { - Exception? error = null; - try - { - while (!ct.IsCancellationRequested) - { - var mem = writer.GetMemory(512 * 1024); - int bytesRead; - try - { - bytesRead = await stream.ReadAsync(mem, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return; - } - catch (Exception) - { - error = new AbruptCloseException(); - return; - } - - if (bytesRead == 0) - { - return; - } - - writer.Advance(bytesRead); - var flush = await writer.FlushAsync(ct).ConfigureAwait(false); - if (flush.IsCompleted || flush.IsCanceled) - { - break; - } - } - } - finally - { - try - { - writer.Complete(error); - } - catch (InvalidOperationException) - { - // noop - } - } - } - - private static async Task DrainPipeToChannel(PipeReader reader, ChannelWriter channel, - Action onClose, CancellationToken ct) - { - var abrupt = false; - try - { - while (!ct.IsCancellationRequested) - { - var result = await reader.ReadAsync(ct).ConfigureAwait(false); - var buffer = result.Buffer; - - foreach (var segment in buffer) - { - var tb = TransportBuffer.Rent(segment.Length); - segment.Span.CopyTo(tb.FullMemory.Span); - tb.Length = segment.Length; - if (!channel.TryWrite(tb)) - { - tb.Dispose(); - } - } - - reader.AdvanceTo(buffer.End); - - if (result.IsCompleted) - { - if (reader.TryRead(out var final) && !final.Buffer.IsEmpty) - { - reader.AdvanceTo(final.Buffer.End); - } - - break; - } - } - } - catch (OperationCanceledException) - { - onClose(); - return; - } - catch (AbruptCloseException) - { - abrupt = true; - onClose(); - return; - } - catch (Exception) - { - abrupt = true; - onClose(); - return; - } - finally - { - try - { - reader.Complete(); - } - catch (InvalidOperationException) - { - // noop - } - - if (abrupt) - { - channel.TryComplete(new AbruptCloseException()); - } - else - { - channel.TryComplete(); - } - } - - onClose(); - } - - private static async Task FillPipeFromChannel(ChannelReader channel, PipeWriter writer, - CancellationToken ct) - { - try - { - while (await channel.WaitToReadAsync(ct).ConfigureAwait(false)) - { - while (channel.TryRead(out var buf)) - { - try - { - var span = writer.GetSpan(buf.Length); - buf.Span.CopyTo(span); - writer.Advance(buf.Length); - } - finally - { - buf.Dispose(); - } - } - - await writer.FlushAsync(ct).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - // noop - } - catch (Exception) - { - // noop - } - finally - { - try - { - writer.Complete(); - } - catch (InvalidOperationException) - { - // noop - } - } - } - - private static async Task DrainPipeToStream(PipeReader reader, Stream stream, Action? onWritesComplete, - Action onClose, CancellationToken ct) - { - try - { - while (true) - { - ReadResult result; - try - { - result = await reader.ReadAsync(ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - onClose(); - return; - } - catch (Exception) - { - onClose(); - return; - } - - var buffer = result.Buffer; - try - { - if (!buffer.IsEmpty) - { - if (buffer.IsSingleSegment) - { - await stream.WriteAsync(buffer.First, ct).ConfigureAwait(false); - } - else - { - using var owner = MemoryPool.Shared.Rent((int)buffer.Length); - buffer.CopyTo(owner.Memory.Span); - await stream.WriteAsync(owner.Memory[..(int)buffer.Length], ct).ConfigureAwait(false); - } - } - } - catch (OperationCanceledException) - { - reader.AdvanceTo(buffer.End); - onClose(); - return; - } - catch (Exception) - { - reader.AdvanceTo(buffer.End); - onClose(); - return; - } - - reader.AdvanceTo(buffer.End); - if (result.IsCompleted) - { - break; - } - } - } - finally - { - try - { - reader.Complete(); - } - catch (InvalidOperationException) - { - // noop - } - } - - onWritesComplete?.Invoke(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Tcp/Client/DnsCache.cs b/src/Servus.Akka/Transport/Tcp/Client/DnsCache.cs deleted file mode 100644 index b51626a44..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/DnsCache.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Concurrent; -using System.Net; - -namespace Servus.Akka.Transport.Tcp.Client; - -internal static class DnsCache -{ - private static readonly ConcurrentDictionary Cache = new(StringComparer.OrdinalIgnoreCase); - - public static TimeSpan Ttl { get; set; } = TimeSpan.FromSeconds(120); - - public static async Task ResolveAsync(string host, CancellationToken ct) - { - if (IPAddress.TryParse(host, out var literal)) - { - return [literal]; - } - - if (Cache.TryGetValue(host, out var entry) && !entry.IsExpired(Ttl)) - { - return entry.Addresses; - } - - var addresses = await Dns.GetHostAddressesAsync(host, ct).ConfigureAwait(false); - - if (addresses.Length > 0) - { - Cache[host] = new DnsEntry(addresses, Environment.TickCount64); - } - - return addresses; - } - - internal static void Clear() => Cache.Clear(); - - private readonly record struct DnsEntry(IPAddress[] Addresses, long TimestampMs) - { - public bool IsExpired(TimeSpan ttl) => Environment.TickCount64 - TimestampMs > (long)ttl.TotalMilliseconds; - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Tcp/Client/ITcpConnectionFactory.cs b/src/Servus.Akka/Transport/Tcp/Client/ITcpConnectionFactory.cs deleted file mode 100644 index 26c980642..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/ITcpConnectionFactory.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Servus.Akka.Transport.Tcp.Client; - -internal interface ITcpConnectionFactory -{ - Task EstablishAsync(TransportOptions options, CancellationToken ct); -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Tcp/Client/TcpClientProvider.cs b/src/Servus.Akka/Transport/Tcp/Client/TcpClientProvider.cs deleted file mode 100644 index 55bc2d2a4..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/TcpClientProvider.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Net.Sockets; -using static Servus.Core.Servus; - -namespace Servus.Akka.Transport.Tcp.Client; - -internal class TcpClientProvider(TcpTransportOptions options) : IAsyncDisposable -{ - private Socket? _socket; - - public EndPoint? LocalEndPoint => _socket?.LocalEndPoint; - public EndPoint? RemoteEndPoint => _socket?.RemoteEndPoint; - - public async Task GetStreamAsync(CancellationToken ct = default) - { - var proxyUri = ResolveProxy(options); - - var connectHost = proxyUri?.Host ?? options.Host; - var connectPort = proxyUri?.Port ?? options.Port; - - _socket = CreateSocket(options.SocketSendBufferSize, options.SocketReceiveBufferSize); - - var dnsActivity = Tracing.StartDnsLookup(connectHost); - IPAddress[] addresses; - try - { - var dnsStart = Stopwatch.GetTimestamp(); - addresses = await DnsCache.ResolveAsync(connectHost, ct).ConfigureAwait(false); - var dnsDuration = Stopwatch.GetElapsedTime(dnsStart).TotalSeconds; - - if (addresses.Length == 0) - { - throw new InvalidOperationException($"Could not resolve any IP addresses for host '{connectHost}'."); - } - - if (dnsActivity is not null) - { - Tracing.SetDnsAnswers(dnsActivity, - Array.ConvertAll(addresses, a => a.ToString())); - } - - Metrics.DnsLookupDuration().Record(dnsDuration, - new KeyValuePair("dns.question.name", connectHost)); - dnsActivity?.Stop(); - Tracing.For("Dns").Debug(this, "Resolved {0} → {1} address(es)", connectHost, addresses.Length); - } - catch (Exception ex) - { - if (dnsActivity is not null) - { - Tracing.SetError(dnsActivity, ex); - dnsActivity.Stop(); - } - - Tracing.For("Dns").Warning(this, "DNS '{0}' failed: {1}", connectHost, ex.Message); - throw; - } - - var networkType = addresses[0].AddressFamily == AddressFamily.InterNetworkV6 - ? "ipv6" - : "ipv4"; - var socketActivity = Tracing.StartSocketConnect( - addresses[0].ToString(), connectPort, "tcp", networkType); - try - { - await _socket.ConnectAsync(addresses, connectPort, ct).ConfigureAwait(false); - socketActivity?.Stop(); - Tracing.For("Connection").Debug(this, "TCP connected to {0}:{1}", addresses[0], connectPort); - } - catch (Exception ex) - { - if (socketActivity is not null) - { - Tracing.SetError(socketActivity, ex); - socketActivity.Stop(); - } - - Tracing.For("Connection").Warning(this, "TCP connect to {0}:{1} failed: {2}", addresses[0], connectPort, ex.Message); - throw; - } - - return new NetworkStream(_socket, ownsSocket: false); - } - - private static Uri? ResolveProxy(TcpTransportOptions options) - { - if (!options.UseProxy || options.Proxy is null) - { - return null; - } - - var targetUri = new Uri($"http://{options.Host}:{options.Port}/"); - - if (options.Proxy.IsBypassed(targetUri)) - { - return null; - } - - if (options.DefaultProxyCredentials is not null && options.Proxy.Credentials is null) - { - options.Proxy.Credentials = options.DefaultProxyCredentials; - } - - return options.Proxy.GetProxy(targetUri); - } - - public ValueTask DisposeAsync() - { - if (_socket is null) - { - return ValueTask.CompletedTask; - } - - try - { - _socket.Close(); - _socket.Dispose(); - } - catch (ObjectDisposedException) - { - } - finally - { - _socket = null; - } - - return ValueTask.CompletedTask; - } - - private static Socket CreateSocket(int? sendBufferSize, int? receiveBufferSize) - { - var result = new Socket(SocketType.Stream, ProtocolType.Tcp) - { - NoDelay = true, - LingerState = new LingerOption(true, 0), - }; - - result.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - - if (sendBufferSize.HasValue) - { - result.SendBufferSize = sendBufferSize.Value; - } - - if (receiveBufferSize.HasValue) - { - result.ReceiveBufferSize = receiveBufferSize.Value; - } - - return result; - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionFactory.cs b/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionFactory.cs deleted file mode 100644 index 580bb9147..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionFactory.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Transport.Tcp.Client; - -internal sealed class TcpConnectionFactory : ITcpConnectionFactory -{ - public async Task EstablishAsync(TransportOptions options, CancellationToken ct) - { - Stream stream; - EndPoint? localEndPoint; - EndPoint? remoteEndPoint; - TransportProtocol protocol; - SecurityInfo? security = null; - - if (options is TlsTransportOptions tlsOpts) - { - var tlsProvider = new TlsClientProvider(tlsOpts); - stream = await tlsProvider.GetStreamAsync(ct).ConfigureAwait(false); - localEndPoint = tlsProvider.LocalEndPoint; - remoteEndPoint = tlsProvider.RemoteEndPoint; - protocol = TransportProtocol.Tls; - - if (tlsProvider.NegotiatedSslProtocol is { } sslProto - && tlsProvider.NegotiatedApplicationProtocol is { } appProto) - { - security = new SecurityInfo(sslProto, appProto); - } - } - else if (options is TcpTransportOptions tcpOpts) - { - var tcpProvider = new TcpClientProvider(tcpOpts); - stream = await tcpProvider.GetStreamAsync(ct).ConfigureAwait(false); - localEndPoint = tcpProvider.LocalEndPoint; - remoteEndPoint = tcpProvider.RemoteEndPoint; - protocol = TransportProtocol.Tcp; - } - else - { - throw new ArgumentException($"Unsupported options type: {options.GetType()}", nameof(options)); - } - - var info = new ConnectionInfo( - localEndPoint ?? new IPEndPoint(IPAddress.Any, 0), - remoteEndPoint ?? new IPEndPoint(IPAddress.Any, 0), - protocol, - security); - - var state = new ClientState(stream); - var cts = new CancellationTokenSource(); - var handle = new ConnectionHandle(state.OutboundWriter, state.InboundReader, cts.Token); - var lease = new ConnectionLease(handle, state, cts, info); - - return lease; - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionManagerActor.cs b/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionManagerActor.cs deleted file mode 100644 index 517b9d9af..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionManagerActor.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Akka.Actor; -using static Servus.Core.Servus; - -namespace Servus.Akka.Transport.Tcp.Client; - -public sealed class TcpConnectionManagerActor : ReceiveActor, IWithTimers -{ - internal sealed record Acquire( - TransportOptions Options, - TaskCompletionSource Tcs, - CancellationToken Token); - - internal sealed record Release(ConnectionLease Lease, bool CanReuse); - - private sealed record Established(ConnectionLease Lease, Acquire Original); - - private sealed record EstablishFailed(Exception Ex, Acquire Original); - - internal sealed class Evict - { - public static readonly Evict Instance = new(); - } - - private sealed class HostState(TransportOptions options, TcpPoolConfig config) - { - public readonly TransportOptions Options = options; - public readonly TcpPoolConfig Config = config; - public readonly List Leases = []; - public readonly Queue Idle = new(); - public readonly Queue Pending = new(); - public int Establishing; - } - - private readonly Dictionary _hosts = new(); - private readonly ITcpConnectionFactory _factory; - private readonly PoolConfigRegistry _registry; - private const string EvictTimerKey = "evict-idle"; - - public ITimerScheduler Timers { get; set; } = null!; - - internal static Task AcquireAsync( - IActorRef actor, TransportOptions options, CancellationToken ct = default) - { - var tcs = new TaskCompletionSource(); - - if (ct.CanBeCanceled) - { - ct.UnsafeRegister( - static (state, token) => ((TaskCompletionSource)state!).TrySetCanceled(token), - tcs); - } - - actor.Tell(new Acquire(options, tcs, ct)); - return tcs.Task; - } - - public TcpConnectionManagerActor(PoolConfigRegistry registry) : this(new TcpConnectionFactory(), - registry) - { - } - - internal TcpConnectionManagerActor(ITcpConnectionFactory factory, PoolConfigRegistry registry) - { - _factory = factory; - _registry = registry; - - Receive(OnAcquire); - Receive(OnRelease); - Receive(OnEstablished); - Receive(OnFailed); - Receive(_ => OnEvict()); - } - - protected override void PreStart() - { - Timers.StartPeriodicTimer(EvictTimerKey, Evict.Instance, - TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); - } - - private void OnAcquire(Acquire msg) - { - if (msg.Tcs.Task.IsCompleted) return; - - var host = GetOrCreateHost(msg.Options); - Tracing.For("Pool").Trace(this, "Acquire {0}:{1}", msg.Options.Host, msg.Options.Port); - - while (host.Idle.TryDequeue(out var idle)) - { - if (idle.IsAlive() && !idle.IsExpired(host.Config.ConnectionLifetime)) - { - if (msg.Tcs.TrySetResult(idle)) - { - Tracing.For("Pool").Debug(this, "Reused idle connection to {0}:{1}", msg.Options.Host, msg.Options.Port); - return; - } - } - else - { - host.Leases.Remove(idle); - idle.Dispose(); - } - } - - if (host.Leases.Count + host.Establishing < host.Config.MaxConnectionsPerHost) - { - Tracing.For("Pool").Debug(this, "Creating connection to {0}:{1}", msg.Options.Host, msg.Options.Port); - Establish(host, msg); - } - else - { - host.Pending.Enqueue(msg); - } - } - - private void OnRelease(Release msg) - { - var options = FindHostKey(msg.Lease); - - if (options is null || !_hosts.TryGetValue(options, out var host)) - { - msg.Lease.Dispose(); - return; - } - - Tracing.For("Pool").Trace(this, "Released {0}:{1}", options.Host, options.Port); - - if (!msg.CanReuse || !msg.Lease.IsAlive()) - { - host.Leases.Remove(msg.Lease); - msg.Lease.Dispose(); - ServeNextPending(host); - return; - } - - while (host.Pending.TryDequeue(out var pending)) - { - if (!pending.Tcs.Task.IsCompleted) - { - if (pending.Tcs.TrySetResult(msg.Lease)) - { - return; - } - } - } - - host.Idle.Enqueue(msg.Lease); - } - - private void OnEstablished(Established msg) - { - var host = GetOrCreateHost(msg.Original.Options); - host.Establishing--; - host.Leases.Add(msg.Lease); - Tracing.For("Pool").Debug(this, "Established to {0}:{1}", msg.Original.Options.Host, msg.Original.Options.Port); - - if (!msg.Original.Tcs.TrySetResult(msg.Lease)) - { - OnRelease(new Release(msg.Lease, CanReuse: true)); - } - } - - private void OnFailed(EstablishFailed msg) - { - if (_hosts.TryGetValue(msg.Original.Options, out var host)) - { - host.Establishing--; - } - - Tracing.For("Pool").Warning(this, "Failed to {0}:{1}: {2}", msg.Original.Options.Host, msg.Original.Options.Port, msg.Ex.Message); - - if (msg.Ex is OperationCanceledException oce) - { - msg.Original.Tcs.TrySetCanceled(oce.CancellationToken); - } - else - { - msg.Original.Tcs.TrySetException(msg.Ex); - } - - if (host is not null) - { - ServeNextPending(host); - } - } - - private void OnEvict() - { - foreach (var host in _hosts.Values) - { - var toRemove = new List(); - var newIdle = new Queue(); - - while (host.Idle.TryDequeue(out var lease)) - { - if (!lease.IsAlive() || lease.IsExpired(host.Config.ConnectionLifetime)) - { - toRemove.Add(lease); - } - else - { - newIdle.Enqueue(lease); - } - } - - while (newIdle.TryDequeue(out var kept)) - { - host.Idle.Enqueue(kept); - } - - foreach (var lease in toRemove) - { - host.Leases.Remove(lease); - lease.Dispose(); - } - } - } - - protected override void PostStop() - { - Timers.CancelAll(); - foreach (var host in _hosts.Values) - { - while (host.Pending.TryDequeue(out var pending)) - { - pending.Tcs.TrySetException(new ObjectDisposedException( - nameof(TcpConnectionManagerActor))); - } - - foreach (var lease in host.Leases) - { - lease.Dispose(); - } - } - - _hosts.Clear(); - } - - private TransportOptions? FindHostKey(ConnectionLease lease) - { - foreach (var (key, host) in _hosts) - { - if (host.Leases.Contains(lease)) - { - return key; - } - } - - return null; - } - - private HostState GetOrCreateHost(TransportOptions options) - { - if (!_hosts.TryGetValue(options, out var state)) - { - var config = _registry.Resolve(options.PoolKey); - state = new HostState(options, config); - _hosts[options] = state; - } - - return state; - } - - private void Establish(HostState host, Acquire msg) - { - host.Establishing++; - _factory - .EstablishAsync(msg.Options, msg.Token) - .PipeTo(Self, - success: lease => new Established(lease, msg), - failure: ex => new EstablishFailed(ex, msg)); - } - - private void ServeNextPending(HostState host) - { - while (host.Pending.TryDequeue(out var next)) - { - if (!next.Tcs.Task.IsCompleted) - { - if (host.Leases.Count + host.Establishing < host.Config.MaxConnectionsPerHost) - { - Establish(host, next); - return; - } - - host.Pending.Enqueue(next); - return; - } - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionStage.cs b/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionStage.cs deleted file mode 100644 index 111e51812..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/TcpConnectionStage.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Transport.Tcp.Client; - -internal sealed class TcpConnectionStage : GraphStage> -{ - private readonly IActorRef _connectionManager; - private readonly IPoolingStrategy _poolingStrategy; - - private readonly Inlet _in = new("TcpConnection.In"); - private readonly Outlet _out = new("TcpConnection.Out"); - - public override FlowShape Shape { get; } - - public TcpConnectionStage(IActorRef connectionManager, IPoolingStrategy poolingStrategy) - { - _connectionManager = connectionManager; - _poolingStrategy = poolingStrategy; - Shape = new FlowShape(_in, _out); - } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new Logic(this); - - [ExcludeFromCodeCoverage] - private sealed class Logic : TimerGraphStageLogic, ITransportOperations - { - private readonly TcpConnectionStage _stage; - private readonly Queue _pendingReads = new(); - private TcpTransportStateMachine _sm = null!; - - public Logic(TcpConnectionStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage._in, - onPush: () => _sm.HandlePush(Grab(stage._in)), - onUpstreamFinish: () => _sm.HandleUpstreamFinish()); - - SetHandler(stage._out, - onPull: () => - { - if (_pendingReads.TryDequeue(out var item)) - { - Push(_stage._out, item); - } - }, - onDownstreamFinish: _ => - { - _sm.HandleDownstreamFinish(); - CompleteStage(); - }); - } - - public override void PreStart() - { - var stageActor = GetStageActor(OnReceive); - _sm = new TcpTransportStateMachine( - this, - _stage._connectionManager, - _stage._poolingStrategy, - stageActor.Ref); - Pull(_stage._in); - } - - private void OnReceive((IActorRef sender, object message) args) - { - if (args.message is ITcpTransportEvent evt) - { - _sm.Dispatch(evt); - } - } - - protected override void OnTimer(object timerKey) - => _sm.OnTimer(timerKey as string); - - public override void PostStop() => _sm.PostStop(); - - void ITransportOperations.OnPushInbound(ITransportInbound item) - { - if (IsAvailable(_stage._out)) - { - Push(_stage._out, item); - } - else - { - _pendingReads.Enqueue(item); - } - } - - void ITransportOperations.OnSignalPullOutbound() - { - if (!IsClosed(_stage._in) && !HasBeenPulled(_stage._in)) - { - Pull(_stage._in); - } - } - - void ITransportOperations.OnCompleteStage() => CompleteStage(); - - void ITransportOperations.OnScheduleTimer(string key, TimeSpan delay) - => ScheduleOnce(key, delay); - - void ITransportOperations.OnCancelTimer(string key) => CancelTimer(key); - - ILoggingAdapter ITransportOperations.Log => Log; - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Client/TcpTransportFactory.cs b/src/Servus.Akka/Transport/Tcp/Client/TcpTransportFactory.cs deleted file mode 100644 index 6bdd48497..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/TcpTransportFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Akka; -using Akka.Actor; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Transport.Tcp.Client; - -public sealed class TcpTransportFactory : ITransportFactory -{ - private readonly IActorRef _connectionManager; - private readonly IPoolingStrategy _poolingStrategy; - - public TcpTransportFactory(IActorRef connectionManager, IPoolingStrategy poolingStrategy) - { - _connectionManager = connectionManager; - _poolingStrategy = poolingStrategy; - } - - public Flow Create() - { - return Flow.FromGraph(new TcpConnectionStage(_connectionManager, _poolingStrategy)); - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Client/TcpTransportStateMachine.cs b/src/Servus.Akka/Transport/Tcp/Client/TcpTransportStateMachine.cs deleted file mode 100644 index e46426eeb..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/TcpTransportStateMachine.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System.Buffers; -using Akka.Actor; -using static Servus.Core.Servus; - -namespace Servus.Akka.Transport.Tcp.Client; - -public sealed class TcpTransportStateMachine -{ - private const string ConnectTimerKey = "connect-timeout"; - - private readonly ITransportOperations _ops; - private readonly IActorRef _connectionManager; - private readonly IPoolingStrategy _poolingStrategy; - private readonly IActorRef _self; - - private ConnectionHandle? _handle; - private ConnectionLease? _currentLease; - private bool _leaseReturned; - private int _connectionGen; - private ConnectTransport? _pendingConnect; - private bool _autoReconnect; - - private readonly Queue _pendingWrites = new(); - - private bool _upstreamFinished; - private bool _isReconnecting; - private TcpPumpManager? _pumpManager; - private CancellationTokenSource? _acquireCts; - - public TcpTransportStateMachine( - ITransportOperations ops, - IActorRef connectionManager, - IPoolingStrategy poolingStrategy, - IActorRef self) - { - _ops = ops; - _connectionManager = connectionManager; - _poolingStrategy = poolingStrategy; - _self = self; - } - - internal void Dispatch(ITcpTransportEvent evt) - { - switch (evt) - { - case LeaseAcquired e: - OnLeaseAcquired(e.Lease); - break; - case AcquisitionFailed e: - OnAcquisitionFailed(e.Error); - break; - case InboundBatch e: - if (e.Gen == _connectionGen) - { - OnInboundBatch(e.Batch, e.Count); - } - else - { - ArrayPool.Shared.Return(e.Batch); - } - break; - case InboundComplete e: - if (e.Gen == _connectionGen) - { - OnInboundComplete(e.Reason); - } - break; - case InboundPumpFailed: - OnInboundComplete(DisconnectReason.Error); - break; - case OutboundWriteDone: - break; - case OutboundWriteFailed e: - OnOutboundWriteFailed(e.Error); - break; - } - } - - public void HandlePush(ITransportOutbound item) - { - switch (item) - { - case ConnectTransport connect: - HandleConnectTransport(connect); - break; - case TransportData data: - HandleTransportData(data); - break; - case DisconnectTransport disconnect: - HandleDisconnectTransport(disconnect); - break; - } - } - - public void HandleUpstreamFinish() - { - _upstreamFinished = true; - if (_handle is null) - { - _ops.OnCompleteStage(); - } - else if (_pendingWrites.Count == 0) - { - _connectionGen++; - _pumpManager?.StopPumps(); - ReturnLeaseToPool(_poolingStrategy.OnUpstreamFinish(_currentLease!)); - _handle = null; - _currentLease = null; - _ops.OnCompleteStage(); - } - } - - public void HandleDownstreamFinish() - { - CleanupTransport(); - } - - public void OnTimer(string? timerKey) - { - if (timerKey != ConnectTimerKey || _pendingConnect is null) - { - return; - } - - _pendingConnect = null; - - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Timeout)); - _ops.OnSignalPullOutbound(); - } - - public void PostStop() - { - _ops.OnCancelTimer(ConnectTimerKey); - CleanupTransport(); - - while (_pendingWrites.TryDequeue(out var orphan)) - { - orphan.Dispose(); - } - } - - private void HandleConnectTransport(ConnectTransport connect) - { - if (connect.Options is TcpTransportOptions tcpOpts) - { - _autoReconnect = tcpOpts.AutoReconnect; - } - - if (_currentLease is not null) - { - _isReconnecting = true; - } - - CleanupTransport(); - _pendingConnect = connect; - AcquireConnection(connect); - _ops.OnSignalPullOutbound(); - } - - private void HandleTransportData(TransportData data) - { - if (_handle is null) - { - _pendingWrites.Enqueue(data.Buffer); - _ops.OnSignalPullOutbound(); - return; - } - - _handle.Write(data.Buffer); - _ops.OnSignalPullOutbound(); - } - - private void HandleDisconnectTransport(DisconnectTransport disconnect) - { - CleanupTransport(); - _ops.OnSignalPullOutbound(); - } - - private void OnLeaseAcquired(ConnectionLease lease) - { - _ops.OnCancelTimer(ConnectTimerKey); - - _pendingConnect = null; - _connectionGen++; - _leaseReturned = false; - _currentLease = lease; - _handle = lease.Handle; - - _pumpManager = new TcpPumpManager(_self); - _pumpManager.StartPumps(lease.State, _connectionGen); - Tracing.For("Connection").Debug(this, "Transport ready"); - - if (_isReconnecting) - { - _isReconnecting = false; - _ops.OnPushInbound(new TransportConnected(_currentLease!.Info)); - } - - FlushPendingWrites(); - } - - private void OnAcquisitionFailed(Exception ex) - { - if (ex is OperationCanceledException) - { - return; - } - - _ops.OnCancelTimer(ConnectTimerKey); - Tracing.For("Connection").Warning(this, "Acquisition failed: {0}", ex.Message); - - if (_pendingConnect is null) - { - return; - } - - _pendingConnect = null; - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Error)); - _ops.OnSignalPullOutbound(); - } - - private void OnInboundBatch(ITransportInbound[] batch, int count) - { - for (var i = 0; i < count; i++) - { - _ops.OnPushInbound(batch[i]); - batch[i] = null!; - } - - ArrayPool.Shared.Return(batch); - } - - private void OnInboundComplete(DisconnectReason reason) - { - Tracing.For("Connection").Debug(this, "Disconnected: {0}", reason); - var poolAction = _poolingStrategy.OnDisconnect(_currentLease!, reason); - - if (_autoReconnect && _pendingConnect is null && !_upstreamFinished) - { - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Transient)); - _isReconnecting = true; - - while (_pendingWrites.TryDequeue(out var orphan)) - { - orphan.Dispose(); - } - - _leaseReturned = false; - ReturnLeaseToPool(poolAction); - _handle = null; - _currentLease = null; - - _ops.OnSignalPullOutbound(); - return; - } - - _ops.OnPushInbound(new TransportDisconnected(reason)); - - _leaseReturned = false; - ReturnLeaseToPool(poolAction); - _pumpManager?.StopPumps(); - _handle = null; - _currentLease = null; - - if (_upstreamFinished) - { - _ops.OnCompleteStage(); - } - else - { - _ops.OnSignalPullOutbound(); - } - } - - private void OnOutboundWriteFailed(Exception ex) - { - Tracing.For("Connection").Warning(this, "Write failed: {0}", ex.Message); - _leaseReturned = false; - ReturnLeaseToPool(_poolingStrategy.OnDisconnect(_currentLease!, DisconnectReason.Error)); - - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Error)); - _pumpManager?.StopPumps(); - _handle = null; - _currentLease = null; - _ops.OnSignalPullOutbound(); - } - - private void AcquireConnection(ConnectTransport connect) - { - _acquireCts?.Cancel(); - _acquireCts?.Dispose(); - _acquireCts = new CancellationTokenSource(); - - TcpConnectionManagerActor.AcquireAsync(_connectionManager, connect.Options, _acquireCts.Token) - .PipeTo(_self, - success: lease => new LeaseAcquired(lease), - failure: ex => new AcquisitionFailed(ex)); - - var timeout = connect.Options.ConnectTimeout; - if (timeout <= TimeSpan.Zero) - { - timeout = TimeSpan.FromSeconds(10); - } - - _ops.OnScheduleTimer(ConnectTimerKey, timeout); - } - - private void ReturnLeaseToPool(PoolAction action) - { - if (_leaseReturned || _currentLease is null) - { - return; - } - - _leaseReturned = true; - var canReuse = action == PoolAction.Reuse; - _connectionManager.Tell(new TcpConnectionManagerActor.Release(_currentLease, canReuse)); - } - - private void CleanupTransport() - { - _connectionGen++; - _pumpManager?.StopPumps(); - - _acquireCts?.Cancel(); - _acquireCts?.Dispose(); - _acquireCts = null; - - if (_currentLease is not null) - { - _leaseReturned = false; - ReturnLeaseToPool(PoolAction.Dispose); - _currentLease.Dispose(); - _currentLease = null; - _handle = null; - } - } - - private void FlushPendingWrites() - { - while (_pendingWrites.TryDequeue(out var buffer)) - { - if (_handle is not null) - { - _handle.Write(buffer); - } - else - { - buffer.Dispose(); - } - } - - _ops.OnSignalPullOutbound(); - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Client/TlsClientProvider.cs b/src/Servus.Akka/Transport/Tcp/Client/TlsClientProvider.cs deleted file mode 100644 index 185bbf741..000000000 --- a/src/Servus.Akka/Transport/Tcp/Client/TlsClientProvider.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System.Buffers; -using System.Net; -using System.Net.Security; - -namespace Servus.Akka.Transport.Tcp.Client; - -internal class TlsClientProvider(TlsTransportOptions options) : IAsyncDisposable -{ - private readonly TcpClientProvider _tcpClientProvider = new(new TcpTransportOptions - { - Host = options.Host, - Port = options.Port, - ConnectTimeout = options.ConnectTimeout, - SocketSendBufferSize = options.SocketSendBufferSize, - SocketReceiveBufferSize = options.SocketReceiveBufferSize, - UseProxy = options.UseProxy, - Proxy = options.Proxy, - DefaultProxyCredentials = options.DefaultProxyCredentials - }); - - private SslStream? _sslStream; - - public EndPoint? LocalEndPoint => _tcpClientProvider.LocalEndPoint; - public EndPoint? RemoteEndPoint => _tcpClientProvider.RemoteEndPoint; - public System.Security.Authentication.SslProtocols? NegotiatedSslProtocol => _sslStream?.SslProtocol; - public SslApplicationProtocol? NegotiatedApplicationProtocol => _sslStream?.NegotiatedApplicationProtocol; - - public async Task GetStreamAsync(CancellationToken ct = default) - { - var networkStream = await _tcpClientProvider.GetStreamAsync(ct).ConfigureAwait(false); - - if (options is { UseProxy: true, Proxy: not null }) - { - var proxyUri = options.Proxy.GetProxy(new Uri($"https://{options.Host}:{options.Port}/")); - if (proxyUri is not null) - { - await EstablishConnectTunnelAsync(networkStream, options.Host, options.Port, - options.Proxy, options.DefaultProxyCredentials, ct).ConfigureAwait(false); - } - } - - _sslStream = new SslStream( - networkStream, - leaveInnerStreamOpen: false, - options.ServerCertificateValidationCallback - ); - - var targetHost = options.TargetHost ?? options.Host; - var authOptions = new SslClientAuthenticationOptions - { - TargetHost = targetHost, - EnabledSslProtocols = options.EnabledSslProtocols, - ClientCertificates = options.ClientCertificates, - ApplicationProtocols = options.ApplicationProtocols, - }; - - try - { - await _sslStream.AuthenticateAsClientAsync(authOptions, ct) - .WaitAsync(options.ConnectTimeout, ct) - .ConfigureAwait(false); - } - catch - { - throw; - } - - return _sslStream; - } - - public static async Task EstablishConnectTunnelAsync( - Stream proxyStream, - string targetHost, - int targetPort, - IWebProxy proxy, - ICredentials? defaultProxyCredentials, - CancellationToken ct) - { - var connectRequest = $"CONNECT {targetHost}:{targetPort} HTTP/1.1\r\nHost: {targetHost}:{targetPort}\r\n"; - - var proxyUri = proxy.GetProxy(new Uri($"https://{targetHost}:{targetPort}/")); - var credentials = proxy.Credentials ?? defaultProxyCredentials; - if (credentials is not null && proxyUri is not null) - { - var credential = credentials.GetCredential(proxyUri, "Basic"); - if (credential is not null) - { - var encoded = Convert.ToBase64String( - System.Text.Encoding.UTF8.GetBytes($"{credential.UserName}:{credential.Password}")); - connectRequest += $"Proxy-Authorization: Basic {encoded}\r\n"; - } - } - - connectRequest += "\r\n"; - - var requestBytes = System.Text.Encoding.ASCII.GetBytes(connectRequest); - await proxyStream.WriteAsync(requestBytes, ct).ConfigureAwait(false); - await proxyStream.FlushAsync(ct).ConfigureAwait(false); - - var responseBuffer = ArrayPool.Shared.Rent(4096); - try - { - var totalRead = 0; - while (totalRead < responseBuffer.Length) - { - var bytesRead = await proxyStream.ReadAsync( - responseBuffer.AsMemory(totalRead, responseBuffer.Length - totalRead), ct).ConfigureAwait(false); - - if (bytesRead == 0) - { - throw new HttpRequestException("Proxy closed connection during CONNECT tunnel establishment."); - } - - totalRead += bytesRead; - - var span = responseBuffer.AsSpan(0, totalRead); - var headerEnd = span.IndexOf("\r\n\r\n"u8); - if (headerEnd >= 0) - { - if (!span.StartsWith("HTTP/1.1 200"u8) && !span.StartsWith("HTTP/1.0 200"u8)) - { - var crIndex = span.IndexOf((byte)'\r'); - var statusLine = System.Text.Encoding.ASCII.GetString(span[..crIndex]); - throw new HttpRequestException($"Proxy CONNECT tunnel failed: {statusLine}"); - } - - return; - } - } - - throw new HttpRequestException("Proxy CONNECT response exceeded buffer size."); - } - finally - { - ArrayPool.Shared.Return(responseBuffer); - } - } - - public async ValueTask DisposeAsync() - { - if (_sslStream is not null) - { - try - { - await _sslStream.DisposeAsync().ConfigureAwait(false); - } - catch (ObjectDisposedException) - { - } - finally - { - _sslStream = null; - } - } - - await _tcpClientProvider.DisposeAsync().ConfigureAwait(false); - } -} diff --git a/src/Servus.Akka/Transport/Tcp/ClientState.cs b/src/Servus.Akka/Transport/Tcp/ClientState.cs deleted file mode 100644 index b723760c3..000000000 --- a/src/Servus.Akka/Transport/Tcp/ClientState.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; -using System.Threading.Channels; - -namespace Servus.Akka.Transport.Tcp; - -internal sealed class ClientState : IDisposable -{ - private static readonly PipeOptions InboundPipeOptions = new( - pool: MemoryPool.Shared, - minimumSegmentSize: 4096, - pauseWriterThreshold: 0, - resumeWriterThreshold: 0, - useSynchronizationContext: false); - - private static readonly PipeOptions OutboundPipeOptions = new( - pool: MemoryPool.Shared, - minimumSegmentSize: 4096, - pauseWriterThreshold: 1024 * 1024, - resumeWriterThreshold: 512 * 1024, - useSynchronizationContext: false); - - private static readonly UnboundedChannelOptions ChannelOptions = new() - { - SingleReader = true, - SingleWriter = true - }; - - public Stream Stream { get; } - public PipeMode Direction { get; } - - public Pipe InboundPipe { get; } - public Pipe OutboundPipe { get; } - - private readonly Channel _inboundChannel; - private readonly Channel _outboundChannel; - - public ChannelReader InboundReader => _inboundChannel.Reader; - public ChannelWriter InboundWriter => _inboundChannel.Writer; - public ChannelReader OutboundReader => _outboundChannel.Reader; - public ChannelWriter OutboundWriter => _outboundChannel.Writer; - - public Action? OnWritesComplete { get; init; } - - public ClientState(Stream stream, PipeMode direction = PipeMode.Bidirectional) - { - Stream = stream; - Direction = direction; - InboundPipe = new Pipe(InboundPipeOptions); - OutboundPipe = new Pipe(OutboundPipeOptions); - _inboundChannel = Channel.CreateUnbounded(ChannelOptions); - _outboundChannel = Channel.CreateUnbounded(ChannelOptions); - } - - public void Dispose() - { - _inboundChannel.Writer.TryComplete(); - _outboundChannel.Writer.TryComplete(); - - while (_inboundChannel.Reader.TryRead(out var buf)) - { - buf.Dispose(); - } - - while (_outboundChannel.Reader.TryRead(out var buf)) - { - buf.Dispose(); - } - - try - { - InboundPipe.Writer.Complete(); - } - catch (InvalidOperationException) - { - // noop - } - - try - { - InboundPipe.Reader.Complete(); - } - catch (InvalidOperationException) - { - // noop - } - - try - { - OutboundPipe.Writer.Complete(); - } - catch (InvalidOperationException) - { - // noop - } - - try - { - OutboundPipe.Reader.Complete(); - } - catch (InvalidOperationException) - { - // noop - } - - Stream.Dispose(); - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Tcp/ConnectionHandle.cs b/src/Servus.Akka/Transport/Tcp/ConnectionHandle.cs deleted file mode 100644 index d5ab142a6..000000000 --- a/src/Servus.Akka/Transport/Tcp/ConnectionHandle.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Threading.Channels; - -namespace Servus.Akka.Transport.Tcp; - -internal sealed class ConnectionHandle -{ - private readonly ChannelWriter _outboundWriter; - private readonly ChannelReader _inboundReader; - private readonly CancellationToken _token; - - public ConnectionHandle( - ChannelWriter outboundWriter, - ChannelReader inboundReader, - CancellationToken token) - { - _outboundWriter = outboundWriter; - _inboundReader = inboundReader; - _token = token; - } - - public void Write(TransportBuffer buffer) - { - if (!_outboundWriter.TryWrite(buffer)) - { - buffer.Dispose(); - } - } - - public bool TryRead(out TransportBuffer? buffer) - { - return _inboundReader.TryRead(out buffer); - } - - public void SignalClose() - { - _outboundWriter.TryComplete(); - } - - public bool IsCancelled => _token.IsCancellationRequested; -} diff --git a/src/Servus.Akka/Transport/Tcp/ConnectionLease.cs b/src/Servus.Akka/Transport/Tcp/ConnectionLease.cs deleted file mode 100644 index cf9075852..000000000 --- a/src/Servus.Akka/Transport/Tcp/ConnectionLease.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace Servus.Akka.Transport.Tcp; - -internal sealed class ConnectionLease : IDisposable -{ - private readonly CancellationTokenSource _cts; - private readonly ClientState _state; - private readonly long _createdTicks = Environment.TickCount64; - private bool _alive = true; - - internal ConnectionLease(ConnectionHandle handle, ClientState state, CancellationTokenSource cts, ConnectionInfo info) - { - Handle = handle; - _state = state; - _cts = cts; - Info = info; - } - - public ConnectionHandle Handle { get; } - public ConnectionInfo Info { get; } - - internal ClientState State => _state; - - public bool IsAlive() => _alive; - - public bool IsExpired(TimeSpan maxLifetime) - { - if (maxLifetime == Timeout.InfiniteTimeSpan) - { - return false; - } - - var elapsed = Environment.TickCount64 - _createdTicks; - var lifetimeMs = (long)maxLifetime.TotalMilliseconds; - return lifetimeMs <= 0 || elapsed > lifetimeMs; - } - - public void Dispose() - { - if (!_alive) - { - return; - } - - _alive = false; - _cts.Cancel(); - _cts.Dispose(); - _state.Dispose(); - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerFactory.cs b/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerFactory.cs deleted file mode 100644 index d64a8317b..000000000 --- a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Akka; -using Akka.Streams.Dsl; - -namespace Servus.Akka.Transport.Tcp.Listener; - -public sealed class TcpListenerFactory : IListenerFactory -{ - public Source, Task> Bind(ListenerOptions options) - { - if (options is not TcpListenerOptions tcpOptions) - { - throw new ArgumentException( - $"Expected {nameof(TcpListenerOptions)} but got {options.GetType().Name}", - nameof(options)); - } - - return Source.FromGraph(new TcpListenerStage(tcpOptions)); - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerStage.cs b/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerStage.cs deleted file mode 100644 index 7477c7578..000000000 --- a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerStage.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net; -using System.Net.Security; -using System.Net.Sockets; -using Akka; -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Akka.Streams.Dsl; -using Akka.Streams.Stage; - -namespace Servus.Akka.Transport.Tcp.Listener; - -internal sealed record TcpClientAccepted(TcpClient Client); - -internal sealed record TcpAcceptFailed(Exception Error); - -internal sealed record TcpConnectionReady(Flow Flow); - -internal sealed record TcpConnectionInitFailed(Exception Error); - -internal sealed class TcpListenerStage - : GraphStageWithMaterializedValue>, Task> -{ - private readonly TcpListenerOptions _options; - - private readonly Outlet> _out = - new("TcpListener.Out"); - - public override SourceShape> Shape { get; } - - public TcpListenerStage(TcpListenerOptions options) - { - _options = options; - Shape = new SourceShape>(_out); - } - - public override ILogicAndMaterializedValue CreateLogicAndMaterializedValue( - Attributes inheritedAttributes) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - return new LogicAndMaterializedValue(new Logic(this, tcs), tcs.Task); - } - - [ExcludeFromCodeCoverage] - private sealed class Logic : GraphStageLogic - { - private readonly TcpListenerStage _stage; - private readonly TaskCompletionSource _boundSignal; - private readonly Queue> _pendingConnections = new(); - private TcpListener? _listener; - private IActorRef _self = null!; - private CancellationTokenSource? _cts; - - public Logic(TcpListenerStage stage, TaskCompletionSource boundSignal) : base(stage.Shape) - { - _stage = stage; - _boundSignal = boundSignal; - - SetHandler(stage._out, onPull: TryPush); - } - - public override void PreStart() - { - var stageActor = GetStageActor(OnReceive); - _self = stageActor.Ref; - _cts = new CancellationTokenSource(); - - var address = IPAddress.TryParse(_stage._options.Host, out var ip) - ? ip - : IPAddress.Any; - - _listener = new TcpListener(address, _stage._options.Port); - - if (_stage._options.ReuseAddress) - { - _listener.Server.SetSocketOption( - SocketOptionLevel.Socket, - SocketOptionName.ReuseAddress, - true); - } - - _listener.Start(_stage._options.Backlog); - _boundSignal.TrySetResult(); - _ = AcceptLoopAsync(_listener, _self, _cts.Token); - } - - public override void PostStop() - { - _cts?.Cancel(); - _cts?.Dispose(); - _cts = null; - - _listener?.Stop(); - _listener = null; - - while (_pendingConnections.TryDequeue(out _)) - { - } - } - - private static async Task AcceptLoopAsync(TcpListener listener, IActorRef self, CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try - { - var client = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); - self.Tell(new TcpClientAccepted(client)); - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - self.Tell(new TcpAcceptFailed(ex)); - return; - } - } - } - - private void OnReceive((IActorRef sender, object message) args) - { - switch (args.message) - { - case TcpClientAccepted accepted: - OnClientAccepted(accepted.Client); - break; - case TcpAcceptFailed failed: - OnAcceptError(failed.Error); - break; - case TcpConnectionReady ready: - _pendingConnections.Enqueue(ready.Flow); - TryPush(); - break; - case TcpConnectionInitFailed failed: - Log.Warning(failed.Error, "Failed to initialize accepted connection"); - break; - } - } - - private void OnClientAccepted(TcpClient client) - { - _ = InitializeConnectionAsync(client); - } - - private async Task InitializeConnectionAsync(TcpClient client) - { - TlsConnectionResult tlsResult; - try - { - if (_stage._options.NoDelay) - { - client.NoDelay = true; - } - - if (_stage._options.SocketSendBufferSize is { } sendBuf) - { - client.SendBufferSize = sendBuf; - } - - if (_stage._options.SocketReceiveBufferSize is { } recvBuf) - { - client.ReceiveBufferSize = recvBuf; - } - - tlsResult = await GetTlsStreamAsync(client); - } - catch (Exception ex) - { - client.Dispose(); - _self.Tell(new TcpConnectionInitFailed(ex)); - return; - } - - var localEndPoint = client.Client.LocalEndPoint!; - var remoteEndPoint = client.Client.RemoteEndPoint!; - - var connectionInfo = new ConnectionInfo( - localEndPoint, - remoteEndPoint, - tlsResult.Security is not null ? TransportProtocol.Tls : TransportProtocol.Tcp, - tlsResult.Security); - - var connectionFlow = Flow.FromGraph( - new TcpServerConnectionStage( - tlsResult.Stream, - connectionInfo, - tlsResult.SslStream, - tlsResult.AllowDelayedNegotiation)); - - _self.Tell(new TcpConnectionReady(connectionFlow)); - } - - private void TryPush() - { - if (IsAvailable(_stage._out) && _pendingConnections.TryDequeue(out var flow)) - { - Push(_stage._out, flow); - } - } - - private async Task GetTlsStreamAsync(TcpClient client) - { - var options = _stage._options; - - if (options.ServerCertificate is null && options.ServerCertificateSelector is null) - { - return new TlsConnectionResult(client.GetStream(), Security: null, SslStream: null, - AllowDelayedNegotiation: false); - } - - return await AuthenticateWithOptionsAsync(client, options); - } - - private async Task AuthenticateWithOptionsAsync(TcpClient client, - TcpListenerOptions options) - { - var sslStream = new SslStream( - client.GetStream(), - leaveInnerStreamOpen: false, - options.ClientCertificateValidationCallback); - - string? hostname = null; - var clientCertRequired = options.ClientCertificateMode is ClientCertificateMode.RequireCertificate - or ClientCertificateMode.AllowCertificate; - - SslServerAuthenticationOptions authOptions; - - if (options.ServerCertificateSelector is { } selector) - { - authOptions = new SslServerAuthenticationOptions - { - ServerCertificateSelectionCallback = (_, host) => - { - hostname = host; - return selector(host) ?? options.ServerCertificate!; - }, - ClientCertificateRequired = clientCertRequired, - EnabledSslProtocols = options.EnabledSslProtocols, - ApplicationProtocols = options.ApplicationProtocols - }; - } - else - { - authOptions = new SslServerAuthenticationOptions - { - ServerCertificate = options.ServerCertificate, - ClientCertificateRequired = clientCertRequired, - EnabledSslProtocols = options.EnabledSslProtocols, - ApplicationProtocols = options.ApplicationProtocols - }; - } - - await sslStream.AuthenticateAsServerAsync(authOptions, CancellationToken.None) - .WaitAsync(options.HandshakeTimeout, CancellationToken.None); - - if (hostname is null && sslStream.TargetHostName is { Length: > 0 } targetHost) - { - hostname = targetHost; - } - - var security = CaptureSecurityInfo(sslStream, hostname); - var allowDelayed = options.ClientCertificateMode is ClientCertificateMode.DelayCertificate; - return new TlsConnectionResult(sslStream, security, sslStream, allowDelayed); - } - - private static SecurityInfo CaptureSecurityInfo(SslStream sslStream, string? hostname) - { - return new SecurityInfo( - sslStream.SslProtocol, - sslStream.NegotiatedApplicationProtocol, - sslStream.NegotiatedCipherSuite, - hostname); - } - - private void OnAcceptError(Exception ex) - { - if (ex is ObjectDisposedException or OperationCanceledException) - { - return; - } - - Log.Error(ex, "TCP listener accept failed"); - FailStage(ex); - } - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/Tcp/Listener/TcpServerConnectionStage.cs b/src/Servus.Akka/Transport/Tcp/Listener/TcpServerConnectionStage.cs deleted file mode 100644 index 943961127..000000000 --- a/src/Servus.Akka/Transport/Tcp/Listener/TcpServerConnectionStage.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.Security; -using Akka.Actor; -using Akka.Event; -using Akka.Streams; -using Akka.Streams.Stage; - -namespace Servus.Akka.Transport.Tcp.Listener; - -internal sealed class TcpServerConnectionStage : GraphStage> -{ - private readonly Stream _stream; - private readonly ConnectionInfo _connectionInfo; - private readonly SslStream? _sslStream; - private readonly bool _allowDelayedNegotiation; - - private readonly Inlet _in = new("TcpServerConnection.In"); - private readonly Outlet _out = new("TcpServerConnection.Out"); - - public override FlowShape Shape { get; } - - public TcpServerConnectionStage( - Stream stream, - ConnectionInfo connectionInfo, - SslStream? sslStream = null, - bool allowDelayedNegotiation = false) - { - _stream = stream; - _connectionInfo = connectionInfo; - _sslStream = sslStream; - _allowDelayedNegotiation = allowDelayedNegotiation; - Shape = new FlowShape(_in, _out); - } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new Logic(this); - - [ExcludeFromCodeCoverage] - private sealed class Logic : TimerGraphStageLogic, ITransportOperations - { - private readonly TcpServerConnectionStage _stage; - private readonly Queue _pendingReads = new(); - private TcpServerStateMachine _sm = null!; - - public Logic(TcpServerConnectionStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage._in, - onPush: () => _sm.HandlePush(Grab(stage._in)), - onUpstreamFinish: () => _sm.HandleUpstreamFinish()); - - SetHandler(stage._out, - onPull: () => - { - if (_pendingReads.TryDequeue(out var item)) - { - Push(_stage._out, item); - } - }, - onDownstreamFinish: _ => - { - _sm.HandleDownstreamFinish(); - CompleteStage(); - }); - } - - public override void PreStart() - { - var stageActor = GetStageActor(OnReceive); - var state = new ClientState(_stage._stream); - _sm = new TcpServerStateMachine( - this, stageActor.Ref, state, _stage._connectionInfo, - _stage._sslStream, _stage._allowDelayedNegotiation); - _sm.Start(); - Pull(_stage._in); - } - - private void OnReceive((IActorRef sender, object message) args) - { - if (args.message is ITcpTransportEvent evt) - { - _sm.Dispatch(evt); - } - } - - protected override void OnTimer(object timerKey) { } - - public override void PostStop() => _sm.PostStop(); - - void ITransportOperations.OnPushInbound(ITransportInbound item) - { - if (IsAvailable(_stage._out)) - { - Push(_stage._out, item); - } - else - { - _pendingReads.Enqueue(item); - } - } - - void ITransportOperations.OnSignalPullOutbound() - { - if (!IsClosed(_stage._in) && !HasBeenPulled(_stage._in)) - { - Pull(_stage._in); - } - } - - void ITransportOperations.OnCompleteStage() => CompleteStage(); - - void ITransportOperations.OnScheduleTimer(string key, TimeSpan delay) - => ScheduleOnce(key, delay); - - void ITransportOperations.OnCancelTimer(string key) => CancelTimer(key); - - ILoggingAdapter ITransportOperations.Log => Log; - } -} diff --git a/src/Servus.Akka/Transport/Tcp/Listener/TcpServerStateMachine.cs b/src/Servus.Akka/Transport/Tcp/Listener/TcpServerStateMachine.cs deleted file mode 100644 index 1ae79f20c..000000000 --- a/src/Servus.Akka/Transport/Tcp/Listener/TcpServerStateMachine.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Buffers; -using System.Net.Security; -using Akka.Actor; - -namespace Servus.Akka.Transport.Tcp.Listener; - -internal sealed class TcpServerStateMachine -{ - private readonly ITransportOperations _ops; - private readonly IActorRef _self; - private readonly ClientState _state; - private readonly ConnectionInfo _connectionInfo; - private readonly SslStream? _sslStream; - private readonly bool _allowDelayedNegotiation; - - private ConnectionHandle? _handle; - private int _connectionGen; - private bool _upstreamFinished; - private TcpPumpManager? _pumpManager; - - public TcpServerStateMachine( - ITransportOperations ops, - IActorRef self, - ClientState state, - ConnectionInfo connectionInfo, - SslStream? sslStream = null, - bool allowDelayedNegotiation = false) - { - _ops = ops; - _self = self; - _state = state; - _connectionInfo = connectionInfo; - _sslStream = sslStream; - _allowDelayedNegotiation = allowDelayedNegotiation; - } - - public void Start() - { - _connectionGen++; - _handle = new ConnectionHandle(_state.OutboundWriter, _state.InboundReader, CancellationToken.None); - - _pumpManager = new TcpPumpManager(_self); - _pumpManager.StartPumps(_state, _connectionGen); - - if (_sslStream is not null || _allowDelayedNegotiation) - { - var baseSecurity = _connectionInfo.Security; - var security = baseSecurity is not null - ? baseSecurity with { SslStream = _sslStream, AllowDelayedNegotiation = _allowDelayedNegotiation } - : new SecurityInfo(default, default, SslStream: _sslStream, AllowDelayedNegotiation: _allowDelayedNegotiation); - _ops.OnPushInbound(new TransportConnected(_connectionInfo with { Security = security })); - } - else - { - _ops.OnPushInbound(new TransportConnected(_connectionInfo)); - } - } - - internal void Dispatch(ITcpTransportEvent evt) - { - switch (evt) - { - case InboundBatch e: - if (e.Gen == _connectionGen) - { - OnInboundBatch(e.Batch, e.Count); - } - else - { - ArrayPool.Shared.Return(e.Batch); - } - break; - case InboundComplete e: - if (e.Gen == _connectionGen) - { - OnInboundComplete(e.Reason); - } - break; - case InboundPumpFailed: - OnInboundComplete(DisconnectReason.Error); - break; - case OutboundWriteDone: - break; - case OutboundWriteFailed: - OnOutboundWriteFailed(); - break; - } - } - - public void HandlePush(ITransportOutbound item) - { - switch (item) - { - case TransportData data: - HandleTransportData(data); - break; - case DisconnectTransport: - Cleanup(); - _ops.OnCompleteStage(); - break; - default: - _ops.OnSignalPullOutbound(); - break; - } - } - - public void HandleUpstreamFinish() - { - _upstreamFinished = true; - Cleanup(); - _ops.OnCompleteStage(); - } - - public void HandleDownstreamFinish() - { - Cleanup(); - } - - public void PostStop() - { - Cleanup(); - } - - private void HandleTransportData(TransportData data) - { - if (_handle is null) - { - data.Buffer.Dispose(); - _ops.OnSignalPullOutbound(); - return; - } - - _handle.Write(data.Buffer); - _ops.OnSignalPullOutbound(); - } - - private void OnInboundBatch(ITransportInbound[] batch, int count) - { - for (var i = 0; i < count; i++) - { - _ops.OnPushInbound(batch[i]); - batch[i] = null!; - } - - ArrayPool.Shared.Return(batch); - } - - private void OnInboundComplete(DisconnectReason reason) - { - _ops.OnPushInbound(new TransportDisconnected(reason)); - _pumpManager?.StopPumps(); - _handle = null; - - if (_upstreamFinished) - { - _ops.OnCompleteStage(); - } - else - { - _ops.OnSignalPullOutbound(); - } - } - - private void OnOutboundWriteFailed() - { - _ops.OnPushInbound(new TransportDisconnected(DisconnectReason.Error)); - _pumpManager?.StopPumps(); - _handle = null; - _ops.OnSignalPullOutbound(); - } - - private void Cleanup() - { - _connectionGen++; - _pumpManager?.StopPumps(); - _pumpManager = null; - _handle = null; - _state.Dispose(); - } -} diff --git a/src/Servus.Akka/Transport/Tcp/TcpPumpManager.cs b/src/Servus.Akka/Transport/Tcp/TcpPumpManager.cs deleted file mode 100644 index 1520c3128..000000000 --- a/src/Servus.Akka/Transport/Tcp/TcpPumpManager.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Buffers; -using Akka.Actor; -using Servus.Akka.Transport.Tcp.Client; - -namespace Servus.Akka.Transport.Tcp; - -internal sealed class TcpPumpManager -{ - private readonly IActorRef _self; - private CancellationTokenSource? _pumpsCts; - - public TcpPumpManager(IActorRef self) - { - _self = self; - } - - public void StartPumps(ClientState state, int gen) - { - _pumpsCts?.Cancel(); - _pumpsCts?.Dispose(); - _pumpsCts = new CancellationTokenSource(); - - var ct = _pumpsCts.Token; - - _ = RunInboundPump(state, gen, ct); - _ = ClientByteMover.MoveChannelToStream(state, () => - { - _self.Tell(new OutboundWriteDone(gen)); - }, ct); - } - - public void StopPumps() - { - _pumpsCts?.Cancel(); - _pumpsCts?.Dispose(); - _pumpsCts = null; - } - - private async Task RunInboundPump(ClientState state, int gen, CancellationToken ct) - { - _ = ClientByteMover.MoveStreamToChannel(state, () => { }, ct); - - var closeKind = DisconnectReason.Graceful; - try - { - while (await state.InboundReader.WaitToReadAsync(ct).ConfigureAwait(false)) - { - var batch = ArrayPool.Shared.Rent(32); - var count = 0; - - while (count < batch.Length && state.InboundReader.TryRead(out var buf)) - { - batch[count++] = new TransportData(buf); - } - - if (count > 0) - { - _self.Tell(new InboundBatch(batch, count, gen)); - } - else - { - ArrayPool.Shared.Return(batch); - } - } - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - _self.Tell(new InboundPumpFailed(ex)); - return; - } - - _self.Tell(new InboundComplete(closeKind, gen)); - } -} diff --git a/src/Servus.Akka/Transport/Tcp/TcpTransportEvent.cs b/src/Servus.Akka/Transport/Tcp/TcpTransportEvent.cs deleted file mode 100644 index c4994d7fa..000000000 --- a/src/Servus.Akka/Transport/Tcp/TcpTransportEvent.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Servus.Akka.Transport.Tcp; - -internal interface ITcpTransportEvent; - -internal readonly record struct LeaseAcquired(ConnectionLease Lease) : ITcpTransportEvent; - -internal readonly record struct AcquisitionFailed(Exception Error) : ITcpTransportEvent; - -internal readonly record struct InboundBatch(ITransportInbound[] Batch, int Count, int Gen) : ITcpTransportEvent; - -internal readonly record struct InboundComplete(DisconnectReason Reason, int Gen) : ITcpTransportEvent; - -internal readonly record struct InboundPumpFailed(Exception Error) : ITcpTransportEvent; - -internal readonly record struct OutboundWriteDone(int Gen) : ITcpTransportEvent; - -internal readonly record struct OutboundWriteFailed(Exception Error) : ITcpTransportEvent; diff --git a/src/Servus.Akka/Transport/TcpListenerOptions.cs b/src/Servus.Akka/Transport/TcpListenerOptions.cs deleted file mode 100644 index 3459f375f..000000000 --- a/src/Servus.Akka/Transport/TcpListenerOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; - -namespace Servus.Akka.Transport; - -public sealed record TcpListenerOptions : ListenerOptions -{ - public bool ReuseAddress { get; init; } = true; - public bool NoDelay { get; init; } = true; - public X509Certificate2? ServerCertificate { get; init; } - public SslProtocols EnabledSslProtocols { get; init; } = SslProtocols.None; - public List? ApplicationProtocols { get; init; } - public RemoteCertificateValidationCallback? ClientCertificateValidationCallback { get; init; } - public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10); - public ClientCertificateMode ClientCertificateMode { get; init; } = ClientCertificateMode.NoCertificate; - public Func? ServerCertificateSelector { get; init; } -} diff --git a/src/Servus.Akka/Transport/TcpPoolConfig.cs b/src/Servus.Akka/Transport/TcpPoolConfig.cs deleted file mode 100644 index 55086fb2c..000000000 --- a/src/Servus.Akka/Transport/TcpPoolConfig.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Servus.Akka.Transport; - -public sealed record TcpPoolConfig( - int MaxConnectionsPerHost, - TimeSpan IdleTimeout, - TimeSpan ConnectionLifetime, - bool ReuseOnUpstreamFinish); diff --git a/src/Servus.Akka/Transport/TcpTransportOptions.cs b/src/Servus.Akka/Transport/TcpTransportOptions.cs deleted file mode 100644 index c95bb032f..000000000 --- a/src/Servus.Akka/Transport/TcpTransportOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Net; - -namespace Servus.Akka.Transport; - -public sealed record TcpTransportOptions : TransportOptions -{ - public bool UseProxy { get; init; } - public IWebProxy? Proxy { get; init; } - public ICredentials? DefaultProxyCredentials { get; init; } - public bool AutoReconnect { get; init; } -} diff --git a/src/Servus.Akka/Transport/TlsConnectionResult.cs b/src/Servus.Akka/Transport/TlsConnectionResult.cs deleted file mode 100644 index 869cfba1f..000000000 --- a/src/Servus.Akka/Transport/TlsConnectionResult.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Net.Security; - -namespace Servus.Akka.Transport; - -internal sealed record TlsConnectionResult( - Stream Stream, - SecurityInfo? Security, - SslStream? SslStream, - bool AllowDelayedNegotiation); diff --git a/src/Servus.Akka/Transport/TlsTransportOptions.cs b/src/Servus.Akka/Transport/TlsTransportOptions.cs deleted file mode 100644 index 033ff3198..000000000 --- a/src/Servus.Akka/Transport/TlsTransportOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Net; -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; - -namespace Servus.Akka.Transport; - -public sealed record TlsTransportOptions : TransportOptions -{ - public string? TargetHost { get; init; } - public bool UseProxy { get; init; } - public IWebProxy? Proxy { get; init; } - public ICredentials? DefaultProxyCredentials { get; init; } - public X509CertificateCollection? ClientCertificates { get; init; } - public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; init; } - public SslProtocols EnabledSslProtocols { get; init; } = SslProtocols.None; - public List? ApplicationProtocols { get; init; } -} diff --git a/src/Servus.Akka/Transport/TransportBuffer.cs b/src/Servus.Akka/Transport/TransportBuffer.cs deleted file mode 100644 index 6004d78ec..000000000 --- a/src/Servus.Akka/Transport/TransportBuffer.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Buffers; -using System.Collections.Concurrent; - -namespace Servus.Akka.Transport; - -public sealed class TransportBuffer : IDisposable -{ - private static readonly ConcurrentStack Pool = new(); - - private static int _maxPoolSize = Environment.ProcessorCount * 4; - - private IMemoryOwner? _owner; - - public int Length { get; set; } - - public Memory Memory => _owner!.Memory[..Length]; - - public ReadOnlySpan Span => _owner!.Memory.Span[..Length]; - - public Memory FullMemory => _owner!.Memory; - - public int Capacity => _owner?.Memory.Length ?? 0; - - public static int MaxPoolSize => _maxPoolSize; - - public static void ConfigurePoolSize(int maxPoolSize) - { - _maxPoolSize = maxPoolSize; - } - - public static TransportBuffer Rent(int minimumSize) - { - var owner = MemoryPool.Shared.Rent(minimumSize); - if (!Pool.TryPop(out var buf)) - { - return new TransportBuffer { _owner = owner }; - } - - buf._owner = owner; - buf.Length = 0; - return buf; - } - - public static implicit operator TransportBuffer(byte[] data) - { - var buf = Rent(data.Length); - data.AsSpan().CopyTo(buf.FullMemory.Span); - buf.Length = data.Length; - return buf; - } - - public void Dispose() - { - var owner = Interlocked.Exchange(ref _owner, null); - owner?.Dispose(); - - if (_maxPoolSize > 0 && Pool.Count < _maxPoolSize) - { - Pool.Push(this); - } - } -} diff --git a/src/Servus.Akka/Transport/TransportFactory.cs b/src/Servus.Akka/Transport/TransportFactory.cs deleted file mode 100644 index 982ebee6e..000000000 --- a/src/Servus.Akka/Transport/TransportFactory.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Akka; -using Akka.Actor; -using Akka.Streams.Dsl; -using Servus.Akka.Transport.Quic.Client; -using Servus.Akka.Transport.Quic.Listener; -using Servus.Akka.Transport.Tcp.Client; -using Servus.Akka.Transport.Tcp.Listener; - -namespace Servus.Akka.Transport; - -public static class TransportFactory -{ - public static Source, Task> CreateTcpListener( - TcpListenerOptions options) - => new TcpListenerFactory().Bind(options); - - public static Source, Task> CreateQuicListener( - QuicListenerOptions options) - => new QuicListenerFactory().Bind(options); - - public static Flow CreateTcpClient(IActorRef connectionManager, - IPoolingStrategy poolingStrategy) - => new TcpTransportFactory(connectionManager, poolingStrategy).Create(); - - public static Flow CreateQuicClient(IActorRef connectionManager) - => new QuicTransportFactory(connectionManager).Create(); - - public static Props CreateTcpConnectionManager(PoolConfigRegistry registry) - => CreateTcpConnectionManager(new TcpConnectionFactory(), registry); - - public static Props CreateQuicConnectionManager() - => CreateQuicConnectionManager(new QuicConnectionFactory()); - - internal static Props CreateTcpConnectionManager(ITcpConnectionFactory factory, PoolConfigRegistry registry) - => Props.CreateBy(new TcpConnectionManagerProducer(factory, registry)); - - internal static Props CreateQuicConnectionManager(IQuicConnectionFactory factory) - => Props.CreateBy(new QuicConnectionManagerProducer(factory)); - - private sealed class TcpConnectionManagerProducer( - ITcpConnectionFactory factory, - PoolConfigRegistry registry) : IIndirectActorProducer - { - public Type ActorType => typeof(TcpConnectionManagerActor); - public ActorBase Produce() => new TcpConnectionManagerActor(factory, registry); - public void Release(ActorBase actor) { } - } - - private sealed class QuicConnectionManagerProducer( - IQuicConnectionFactory factory) : IIndirectActorProducer - { - public Type ActorType => typeof(QuicConnectionManagerActor); - public ActorBase Produce() => new QuicConnectionManagerActor(factory); - public void Release(ActorBase actor) { } - } -} \ No newline at end of file diff --git a/src/Servus.Akka/Transport/TransportOptions.cs b/src/Servus.Akka/Transport/TransportOptions.cs deleted file mode 100644 index 23eeed6cd..000000000 --- a/src/Servus.Akka/Transport/TransportOptions.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Servus.Akka.Transport; - -public abstract record TransportOptions -{ - public required string Host { get; init; } - public required ushort Port { get; init; } - public string? PoolKey { get; init; } - public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); - public int? SocketSendBufferSize { get; init; } - public int? SocketReceiveBufferSize { get; init; } - - public virtual bool Equals(TransportOptions? other) - { - if (other is null) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - - return GetType() == other.GetType() - && string.Equals(Host, other.Host, StringComparison.OrdinalIgnoreCase) - && Port == other.Port; - } - - public override int GetHashCode() - { - var hash = new HashCode(); - hash.Add(GetType()); - hash.Add(Host, StringComparer.OrdinalIgnoreCase); - hash.Add(Port); - return hash.ToHashCode(); - } -} diff --git a/src/Servus.Akka/Transport/TransportProtocol.cs b/src/Servus.Akka/Transport/TransportProtocol.cs deleted file mode 100644 index b7407e6b2..000000000 --- a/src/Servus.Akka/Transport/TransportProtocol.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Servus.Akka.Transport; - -public enum TransportProtocol -{ - None = 0, - Tcp, - Tls, - Quic -} diff --git a/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt b/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt index 0d7629ce8..87cf548a5 100644 --- a/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt +++ b/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt @@ -12,61 +12,76 @@ namespace TurboHTTP.Client public sealed class CacheOptions { public CacheOptions() { } - public long MaxBodyBytes { get; set; } + public long MaxBodySize { get; set; } public int MaxEntries { get; set; } + public long MaxTotalSize { get; set; } public bool SharedCache { get; set; } } public sealed class CompressionOptions { public CompressionOptions() { } public string Encoding { get; set; } - public long MinBodySizeBytes { get; set; } + public long MinBodySize { get; set; } } public sealed class Expect100Options { public Expect100Options() { } - public long MinBodySizeBytes { get; set; } + public long MinBodySize { get; set; } } public static class Extensions { public static Akka.Streams.Dsl.Source AsEventStream(this System.Net.Http.HttpResponseMessage response) { } public static System.Threading.Tasks.ValueTask GetResponseAsync(this System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken ct = default) { } + public static System.Net.Http.HttpRequestMessage WithFirstPartyContext(this System.Net.Http.HttpRequestMessage request, System.Uri firstParty) { } + public static System.Net.Http.HttpRequestMessage WithTimeout(this System.Net.Http.HttpRequestMessage request, System.TimeSpan timeout) { } } - public sealed class Http1Options + public sealed class Http1ClientOptions { - public Http1Options() { } + public Http1ClientOptions() { } public bool AutoAcceptEncoding { get; set; } public bool AutoHost { get; set; } + public int MaxBufferedResponseBodySize { get; set; } + public int MaxChunkExtensionLength { get; set; } public int MaxConnectionsPerServer { get; set; } public int MaxPipelineDepth { get; set; } public int MaxReconnectAttempts { get; set; } + public int MaxResponseHeaderCount { get; set; } + public int MaxResponseHeaderLineLength { get; set; } public int MaxResponseHeadersLength { get; set; } } - public sealed class Http2Options + public sealed class Http2ClientOptions { - public Http2Options() { } + public Http2ClientOptions() { } + public bool EnableAdaptiveWindowScaling { get; set; } public int HeaderTableSize { get; set; } public int InitialConnectionWindowSize { get; set; } public int InitialStreamWindowSize { get; set; } public System.TimeSpan KeepAlivePingDelay { get; set; } public System.Net.Http.HttpKeepAlivePingPolicy KeepAlivePingPolicy { get; set; } public System.TimeSpan KeepAlivePingTimeout { get; set; } + public long MaxBufferedRequestBodySize { get; set; } public int MaxConcurrentStreams { get; set; } public int MaxConnectionsPerServer { get; set; } public int MaxFrameSize { get; set; } public int MaxReconnectAttempts { get; set; } + public int MaxReconnectBufferSize { get; set; } + public long MaxRequestBodyBufferSize { get; set; } + public int MaxResponseHeaderListSize { get; set; } + public int MaxStreamWindowSize { get; set; } + public double WindowScaleThresholdMultiplier { get; set; } } - public sealed class Http3Options + public sealed class Http3ClientOptions { - public Http3Options() { } - public bool AllowConnectionMigration { get; set; } + public Http3ClientOptions() { } public bool EnableAltSvcDiscovery { get; set; } public System.TimeSpan IdleTimeout { get; set; } + public long MaxBufferedRequestBodySize { get; set; } public int MaxConcurrentStreams { get; set; } public int MaxConnectionsPerServer { get; set; } public int MaxFieldSectionSize { get; set; } public int MaxReconnectAttempts { get; set; } public int MaxReconnectBufferSize { get; set; } + public long MaxRequestBodyBufferSize { get; set; } public int QpackBlockedStreams { get; set; } public int QpackMaxTableCapacity { get; set; } } @@ -114,16 +129,18 @@ namespace TurboHTTP.Client public System.Net.ICredentials? DefaultProxyCredentials { get; set; } public System.Net.Security.RemoteCertificateValidationCallback? EffectiveServerCertificateValidationCallback { get; } public System.Security.Authentication.SslProtocols EnabledSslProtocols { get; set; } - public TurboHTTP.Client.Http1Options Http1 { get; init; } - public TurboHTTP.Client.Http2Options Http2 { get; init; } - public TurboHTTP.Client.Http3Options Http3 { get; init; } - public long MaxBufferedBodySize { get; set; } - public uint MaxEndpointSubstreams { get; set; } - public long? MaxStreamedBodySize { get; set; } + public TurboHTTP.Client.Http1ClientOptions Http1 { get; init; } + public TurboHTTP.Client.Http2ClientOptions Http2 { get; init; } + public TurboHTTP.Client.Http3ClientOptions Http3 { get; init; } + public uint MaxConcurrentEndpoints { get; set; } + public long? MaxStreamedResponseBodySize { get; set; } + public int MinimumSegmentSize { get; set; } public System.TimeSpan PooledConnectionIdleTimeout { get; set; } public System.TimeSpan PooledConnectionLifetime { get; set; } public bool PreAuthenticate { get; set; } public System.Net.IWebProxy? Proxy { get; set; } + public int ReceiveBufferHint { get; set; } + public int RequestBodyChunkSize { get; set; } public System.Net.Security.RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } public int? SocketReceiveBufferSize { get; set; } public int? SocketSendBufferSize { get; set; } @@ -177,14 +194,16 @@ namespace TurboHTTP.Client } public class TurboRequestOptions : System.IEquatable { - public TurboRequestOptions(System.Uri? BaseAddress, System.Net.Http.Headers.HttpRequestHeaders DefaultRequestHeaders, System.Version DefaultRequestVersion, System.Net.Http.HttpVersionPolicy DefaultVersionPolicy, System.TimeSpan Timeout, System.Net.ICredentials? Credentials = null, bool PreAuthenticate = false) { } + public TurboRequestOptions(System.Uri? BaseAddress, System.Net.Http.Headers.HttpRequestHeaders DefaultRequestHeaders, System.Version DefaultRequestVersion, System.Net.Http.HttpVersionPolicy DefaultVersionPolicy, System.TimeSpan Timeout, System.Net.ICredentials? Credentials = null, bool PreAuthenticate = false, bool UseProxy = true, System.Net.IWebProxy? Proxy = null) { } public System.Uri? BaseAddress { get; init; } public System.Net.ICredentials? Credentials { get; init; } public System.Net.Http.Headers.HttpRequestHeaders DefaultRequestHeaders { get; init; } public System.Version DefaultRequestVersion { get; init; } public System.Net.Http.HttpVersionPolicy DefaultVersionPolicy { get; init; } public bool PreAuthenticate { get; init; } + public System.Net.IWebProxy? Proxy { get; init; } public System.TimeSpan Timeout { get; init; } + public bool UseProxy { get; init; } } } namespace TurboHTTP.Diagnostics @@ -193,10 +212,10 @@ namespace TurboHTTP.Diagnostics { public static OpenTelemetry.Metrics.MeterProviderBuilder AddTurboHttpInstrumentation(this OpenTelemetry.Metrics.MeterProviderBuilder builder) { } public static OpenTelemetry.Trace.TracerProviderBuilder AddTurboHttpInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder builder) { } - public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTurboLoggerTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, Servus.Core.Diagnostics.TraceLevel minimumLevel = 1, System.Func? categoryFilter = null) { } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTurboLoggerTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, Servus.Diagnostics.TraceLevel minimumLevel = 1, System.Func? categoryFilter = null) { } public static OpenTelemetry.Metrics.MeterProviderBuilder AddTurboServerInstrumentation(this OpenTelemetry.Metrics.MeterProviderBuilder builder) { } public static OpenTelemetry.Trace.TracerProviderBuilder AddTurboServerInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder builder) { } - public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTurboTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, Servus.Core.Diagnostics.IServusTraceListener listener, Servus.Core.Diagnostics.TraceLevel minimumLevel = 1, System.Func? categoryFilter = null) { } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTurboTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, Servus.Diagnostics.IServusTraceListener listener, Servus.Diagnostics.TraceLevel minimumLevel = 1, System.Func? categoryFilter = null) { } } } namespace TurboHTTP.Features.Caching @@ -333,59 +352,7 @@ namespace TurboHTTP.Server.Context.Features } namespace TurboHTTP.Server.Context { - public interface ITurboFormCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - int Count { get; } - TurboHTTP.Server.Context.ITurboFormFileCollection Files { get; } - Microsoft.Extensions.Primitives.StringValues this[string key] { get; } - System.Collections.Generic.ICollection Keys { get; } - bool ContainsKey(string key); - } - public interface ITurboFormFile - { - string ContentType { get; } - string FileName { get; } - long Length { get; } - string Name { get; } - void CopyTo(System.IO.Stream target); - System.Threading.Tasks.Task CopyToAsync(System.IO.Stream target, System.Threading.CancellationToken cancellationToken = default); - System.IO.Stream OpenReadStream(); - } - public interface ITurboFormFileCollection : System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - int Count { get; } - TurboHTTP.Server.Context.ITurboFormFile this[int index] { get; } - TurboHTTP.Server.Context.ITurboFormFile? this[string name] { get; } - TurboHTTP.Server.Context.ITurboFormFile? GetFile(string name); - System.Collections.Generic.IReadOnlyList GetFiles(string name); - } - public interface ITurboHeaderDictionary : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - long? ContentLength { get; set; } - int Count { get; } - Microsoft.Extensions.Primitives.StringValues this[string key] { get; set; } - System.Collections.Generic.ICollection Keys { get; } - void Add(string key, Microsoft.Extensions.Primitives.StringValues value); - void Clear(); - bool ContainsKey(string key); - bool Remove(string key); - bool TryGetValue(string key, out Microsoft.Extensions.Primitives.StringValues value); - } - public interface ITurboQueryCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - int Count { get; } - Microsoft.Extensions.Primitives.StringValues this[string key] { get; } - System.Collections.Generic.ICollection Keys { get; } - bool ContainsKey(string key); - bool TryGetValue(string key, out Microsoft.Extensions.Primitives.StringValues value); - } - public interface ITurboRequestCookieCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - int Count { get; } - string? this[string key] { get; } - System.Collections.Generic.ICollection Keys { get; } - bool ContainsKey(string key); - } + public interface ITurboHeaderDictionary : Microsoft.AspNetCore.Http.IHeaderDictionary, System.Collections.Generic.ICollection>, System.Collections.Generic.IDictionary, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable { } } namespace TurboHTTP.Server { @@ -394,42 +361,57 @@ namespace TurboHTTP.Server public Http1ServerOptions() { } public System.TimeSpan BodyReadTimeout { get; set; } public System.TimeSpan? KeepAliveTimeout { get; set; } + public int MaxBufferedRequestBodySize { get; set; } public int MaxChunkExtensionLength { get; set; } - public int MaxHeaderListSize { get; set; } + public int? MaxHeaderListSize { get; set; } public int MaxPipelinedRequests { get; set; } - public long MaxRequestBodySize { get; set; } + public long? MaxRequestBodySize { get; set; } public int MaxRequestLineLength { get; set; } public int MaxRequestTargetLength { get; set; } + public double? MinRequestBodyDataRate { get; set; } + public System.TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } + public double? MinResponseDataRate { get; set; } + public System.TimeSpan? MinResponseDataRateGracePeriod { get; set; } public System.TimeSpan? RequestHeadersTimeout { get; set; } } public sealed class Http2ServerOptions { public Http2ServerOptions() { } + public bool EnableAdaptiveWindowScaling { get; set; } public int HeaderTableSize { get; set; } public int InitialConnectionWindowSize { get; set; } public int InitialStreamWindowSize { get; set; } - public System.TimeSpan KeepAliveTimeout { get; set; } + public System.TimeSpan KeepAlivePingDelay { get; set; } + public System.TimeSpan KeepAlivePingTimeout { get; set; } + public System.TimeSpan? KeepAliveTimeout { get; set; } public int MaxConcurrentStreams { get; set; } public int MaxFrameSize { get; set; } - public int MaxHeaderListSize { get; set; } - public long MaxRequestBodySize { get; set; } - public long MaxResponseBufferSize { get; set; } - public int MinRequestBodyDataRate { get; set; } - public System.TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } - public System.TimeSpan RequestHeadersTimeout { get; set; } + public int? MaxHeaderListSize { get; set; } + public long? MaxRequestBodySize { get; set; } + public long? MaxResponseBufferSize { get; set; } + public int MaxStreamWindowSize { get; set; } + public double? MinRequestBodyDataRate { get; set; } + public System.TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } + public double? MinResponseDataRate { get; set; } + public System.TimeSpan? MinResponseDataRateGracePeriod { get; set; } + public System.TimeSpan? RequestHeadersTimeout { get; set; } + public double WindowScaleThresholdMultiplier { get; set; } } public sealed class Http3ServerOptions { public Http3ServerOptions() { } - public bool EnableWebTransport { get; set; } - public System.TimeSpan KeepAliveTimeout { get; set; } + public System.TimeSpan? KeepAliveTimeout { get; set; } public int MaxConcurrentStreams { get; set; } - public int MaxHeaderListSize { get; set; } - public long MaxRequestBodySize { get; set; } - public int MinRequestBodyDataRate { get; set; } - public System.TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } + public int? MaxHeaderListSize { get; set; } + public long? MaxRequestBodySize { get; set; } + public long? MaxResponseBufferSize { get; set; } + public double? MinRequestBodyDataRate { get; set; } + public System.TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } + public double? MinResponseDataRate { get; set; } + public System.TimeSpan? MinResponseDataRateGracePeriod { get; set; } + public int QpackBlockedStreams { get; set; } public int QpackMaxTableCapacity { get; set; } - public System.TimeSpan RequestHeadersTimeout { get; set; } + public System.TimeSpan? RequestHeadersTimeout { get; set; } } [System.Flags] public enum HttpProtocols @@ -451,6 +433,15 @@ namespace TurboHTTP.Server public required Servus.Akka.Transport.IListenerFactory Factory { get; init; } public required Servus.Akka.Transport.ListenerOptions Options { get; init; } } + public sealed class TransportBufferOptions + { + public TransportBufferOptions() { } + public long? InputPauseThreshold { get; set; } + public long? InputResumeThreshold { get; set; } + public int? MinimumSegmentSize { get; set; } + public long? OutputPauseThreshold { get; set; } + public long? OutputResumeThreshold { get; set; } + } public sealed class TurboHttpsOptions { public TurboHttpsOptions() { } @@ -469,6 +460,7 @@ namespace TurboHTTP.Server public System.Net.IPAddress Address { get; } public ushort Port { get; } public TurboHTTP.Server.HttpProtocols Protocols { get; set; } + public TurboHTTP.Server.TransportBufferOptions? Transport { get; set; } public void UseConnectionLogging() { } public void UseConnectionLogging(string loggerName) { } public void UseHttps() { } @@ -492,10 +484,12 @@ namespace TurboHTTP.Server public TurboServerLimits() { } public System.TimeSpan KeepAliveTimeout { get; set; } public int MaxConcurrentConnections { get; set; } - public int MaxConcurrentUpgradedConnections { get; set; } public long MaxRequestBodySize { get; set; } + public long? MaxRequestBufferSize { get; set; } public int MaxRequestHeaderCount { get; set; } public int MaxRequestHeadersTotalSize { get; set; } + public int MaxResetStreamsPerWindow { get; set; } + public long MaxResponseBufferSize { get; set; } public double MinRequestBodyDataRate { get; set; } public System.TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } public double MinResponseDataRate { get; set; } @@ -505,7 +499,7 @@ namespace TurboHTTP.Server public sealed class TurboServerOptions { public TurboServerOptions() { } - public int BodyBufferThreshold { get; set; } + public bool AllowResponseHeaderCompression { get; set; } public System.TimeSpan BodyConsumptionTimeout { get; set; } public System.Collections.Generic.IList Endpoints { get; } public System.TimeSpan GracefulShutdownTimeout { get; set; } @@ -515,6 +509,7 @@ namespace TurboHTTP.Server public TurboHTTP.Server.Http2ServerOptions Http2 { get; } public TurboHTTP.Server.Http3ServerOptions Http3 { get; } public TurboHTTP.Server.TurboServerLimits Limits { get; } + public int MaxOutboundCoalesceCount { get; set; } public int ResponseBodyChunkSize { get; set; } public System.Collections.Generic.IList Urls { get; } public void Bind(Servus.Akka.Transport.QuicListenerOptions options) { } diff --git a/src/TurboHTTP.AcceptanceTests/Diagnostics/LoggingBridgeSpec.cs b/src/TurboHTTP.AcceptanceTests/Diagnostics/LoggingBridgeSpec.cs deleted file mode 100644 index 8ca69db28..000000000 --- a/src/TurboHTTP.AcceptanceTests/Diagnostics/LoggingBridgeSpec.cs +++ /dev/null @@ -1,280 +0,0 @@ -using TurboHTTP.Client; -using System.Collections.Concurrent; -using System.Net; -using System.Net.Sockets; -using System.Text; -using Akka.Actor; -using Akka.Configuration; -using Akka.DependencyInjection; -using Akka.Hosting.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TurboHTTP.Diagnostics; - -namespace TurboHTTP.AcceptanceTests.Diagnostics; - -[CollectionDefinition("Logging", DisableParallelization = true)] -public sealed class LoggingCollectionDefinition; - -[Collection("Logging")] -public sealed class LoggingBridgeSpec : IAsyncLifetime -{ - private static readonly Config LoggingHocon = ConfigurationFactory.ParseString(""" - akka.loggers = ["Akka.Hosting.Logging.LoggerFactoryLogger, Akka.Hosting"] - akka.loglevel = DEBUG - """); - - private Microsoft.Extensions.DependencyInjection.ServiceProvider? _provider; - private CapturingLoggerProvider _capture = null!; - private ITurboHttpClient? _client; - private readonly CancellationTokenSource _serverCts = new(); - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public async ValueTask DisposeAsync() - { - Servus.Core.Servus.Tracing.Disable(); - await _serverCts.CancelAsync(); - _serverCts.Dispose(); - - if (_client is not null) - { - _client.Requests.TryComplete(); - try - { - await _client.Responses.Completion.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch - { - // ignored - } - - _client.Dispose(); - } - - if (_provider is not null) - { - var system = _provider.GetService(); - if (system is not null) - { - await system.Terminate().WaitAsync(TimeSpan.FromSeconds(10)); - await system.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(5)); - await Task.Delay(TimeSpan.FromMilliseconds(250)); - } - - await _provider.DisposeAsync(); - } - } - - private ITurboHttpClient BuildClientViaUserDI(int serverPort, bool withTurboTrace = false) - { - _capture = new CapturingLoggerProvider(); - - var services = new ServiceCollection(); - - // User step 1: register logging - services.AddLogging(b => - { - b.SetMinimumLevel(LogLevel.Debug); - b.AddProvider(_capture); - }); - - // Register ActorSystem as a DI singleton — uses the same ILoggerFactory that - // AddLogging() provides, so the Akka→MEL bridge and the capture provider share - // the exact same factory instance. - services.AddSingleton(sp => - { - var loggerFactory = sp.GetRequiredService(); - var diSetup = DependencyResolverSetup.Create(sp); - var setup = BootstrapSetup.Create() - .WithConfig(LoggingHocon) - .And(diSetup) - .And(new LoggerFactorySetup(loggerFactory)); - return ActorSystem.Create("turbohttp-bridge-test", setup); - }); - - // User step 2: register TurboHttp client - services.AddTurboHttpClient(opts => - { - opts.BaseAddress = new Uri($"http://127.0.0.1:{serverPort}"); - opts.DangerousAcceptAnyServerCertificate = true; - }); - - // User step 3 (optional): route TurboTrace events to MEL - if (withTurboTrace) - { - services.AddTurboLoggerTracing(); - } - - _provider = services.BuildServiceProvider(); - - // Eagerly resolve the trace listener so TurboTrace.Configure() is called - // before the stream materializes on the first request. - if (withTurboTrace) - { - _ = _provider.GetRequiredService(); - } - - var factory = _provider.GetRequiredService(); - _client = factory.CreateClient(string.Empty); - _client.BaseAddress = new Uri($"http://127.0.0.1:{serverPort}"); - _client.DefaultRequestVersion = new Version(1, 1); - _client.Timeout = TimeSpan.FromMinutes(1); - - return _client; - } - - private int StartFakeTcpServer() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - var ct = _serverCts.Token; - - _ = Task.Run(async () => - { - try - { - while (!ct.IsCancellationRequested) - { - var client = await listener.AcceptTcpClientAsync(ct); - _ = ServeConnectionAsync(client, ct); - } - } - catch (OperationCanceledException) - { - } - finally - { - listener.Stop(); - } - }, CancellationToken.None); - - return port; - } - - private static async Task ServeConnectionAsync(TcpClient client, CancellationToken ct) - { - try - { - using var _ = client; - var stream = client.GetStream(); - var buffer = new byte[8192]; - var total = 0; - - while (total < buffer.Length) - { - var n = await stream.ReadAsync(buffer.AsMemory(total), ct); - if (n == 0) - { - return; - } - - total += n; - if (Encoding.ASCII.GetString(buffer, 0, total).Contains("\r\n\r\n")) - { - break; - } - } - - var response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nHello"u8; - await stream.WriteAsync(response.ToArray(), ct); - await stream.FlushAsync(ct); - } - catch (OperationCanceledException) - { - } - catch (Exception) - { - // ignored - } - } - - [Fact(Timeout = 20000, Skip = "Wait for new ServusTrace")] - public async Task Akka_bridge_should_route_pipeline_materialized_message_to_MEL() - { - // Verifies that "Stream pipeline materialized successfully" (Debug) from - // ClientStreamOwnerActor reaches the capturing provider. - var port = StartFakeTcpServer(); - var client = BuildClientViaUserDI(port); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/hello"), cts.Token); - await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); - - var entries = _capture.Entries.ToList(); - Assert.Contains(entries, e => - e.Level == LogLevel.Debug && - e.Message.Contains("materialized", StringComparison.OrdinalIgnoreCase)); - } - - [Fact(Timeout = 20000, Skip = "Wait for new ServusTrace")] - public async Task TurboTrace_request_events_should_route_to_MEL_via_AddTurboLoggerTracing() - { - // Verifies that TracingBidiStage emits "Request started" / "Request completed" - // to the TurboHttp.Trace.Request MEL category when AddTurboLoggerTracing() is called. - var port = StartFakeTcpServer(); - var client = BuildClientViaUserDI(port, withTurboTrace: true); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/hello"), cts.Token); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); - - var entries = _capture.Entries.ToList(); - - Assert.Contains(entries, e => - e is { CategoryName: "TurboHTTP.Trace.Request", Level: LogLevel.Information } && - e.Message.Contains("Request started:", StringComparison.OrdinalIgnoreCase)); - - Assert.Contains(entries, e => - e is { CategoryName: "TurboHTTP.Trace.Request", Level: LogLevel.Information } && - e.Message.Contains("Request completed:", StringComparison.OrdinalIgnoreCase)); - } - - [Fact(Timeout = 20000, Skip = "Wait for new ServusTrace")] - public async Task TurboTrace_connection_events_should_route_to_MEL_via_AddTurboLoggerTracing() - { - // Verifies that DirectConnectionFactory emits "Connection opened" to the - // TurboHttp.Trace.Connection MEL category when AddTurboLoggerTracing() is called. - var port = StartFakeTcpServer(); - var client = BuildClientViaUserDI(port, withTurboTrace: true); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/hello"), cts.Token); - await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); - - var entries = _capture.Entries.ToList(); - - Assert.Contains(entries, e => - e is { CategoryName: "TurboHTTP.Trace.Connection", Level: LogLevel.Information } && - e.Message.Contains("Connection opened:", StringComparison.OrdinalIgnoreCase)); - } - - private sealed class CapturingLoggerProvider : ILoggerProvider - { - public ConcurrentBag Entries { get; } = []; - - public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries); - - public void Dispose() - { - } - } - - private sealed class CapturingLogger(string categoryName, ConcurrentBag entries) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - entries.Add(new LogEntry(categoryName, logLevel, formatter(state, exception))); - } - } - - private sealed record LogEntry(string CategoryName, LogLevel Level, string Message); -} \ No newline at end of file diff --git a/src/TurboHTTP.AcceptanceTests/H11/ConcurrencySpec.cs b/src/TurboHTTP.AcceptanceTests/H11/ConcurrencySpec.cs index dac20f107..9a0801e01 100644 --- a/src/TurboHTTP.AcceptanceTests/H11/ConcurrencySpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H11/ConcurrencySpec.cs @@ -56,7 +56,7 @@ public async Task Concurrency_should_succeed_with_3_parallel_posts() Assert.All(responses, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode)); } - [Fact(Timeout = 5000)] + [Fact(Timeout = 15000)] [Trait("RFC", "RFC9112-9.3")] public async Task Concurrency_should_succeed_with_sequential_burst_of_20_requests() { diff --git a/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs b/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs index 779dcd128..c5e9de570 100644 --- a/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs @@ -196,7 +196,7 @@ public async Task ErrorHandling_should_return_4xx_status_code_400() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Fact(Timeout = 5000)] + [Fact(Timeout = 10000)] [Trait("RFC", "RFC9110-15.5")] public async Task ErrorHandling_should_return_4xx_status_code_401() { diff --git a/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs b/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs index 8a9ee0c1a..7d92d03d8 100644 --- a/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs @@ -115,7 +115,7 @@ public async Task Redirect_should_follow_get_308_to_hello() Assert.Equal("Hello World", body); } - [Theory(Timeout = 5000)] + [Theory(Timeout = 10000)] [InlineData(1)] [InlineData(3)] [InlineData(5)] diff --git a/src/TurboHTTP.AcceptanceTests/H2/ConcurrencySpec.cs b/src/TurboHTTP.AcceptanceTests/H2/ConcurrencySpec.cs index 58b6b3228..a40081d28 100644 --- a/src/TurboHTTP.AcceptanceTests/H2/ConcurrencySpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H2/ConcurrencySpec.cs @@ -135,9 +135,15 @@ public async Task SixtyFour_concurrent_heavy_posts_should_complete_in_repeated_b { const int count = 8; var enc = new HpackEncoder(useHuffman: false); - var settings = new SettingsFrame([]).Serialize(); + var settings = new SettingsFrame( + [(SettingsParameter.InitialWindowSize, (uint)(1 * 1024 * 1024))]).Serialize(); + var connWindowUpdate = new WindowUpdateFrame(0, 16 * 1024 * 1024).Serialize(); - var frameBuffers = new List { settings }; + var settingsAndWindow = new byte[settings.Length + connWindowUpdate.Length]; + settings.CopyTo(settingsAndWindow, 0); + connWindowUpdate.CopyTo(settingsAndWindow, settings.Length); + + var frameBuffers = new List { settingsAndWindow }; for (var i = 0; i < count; i++) { var streamId = 1 + i * 2; diff --git a/src/TurboHTTP.AcceptanceTests/H2/RequestFormatSpec.cs b/src/TurboHTTP.AcceptanceTests/H2/RequestFormatSpec.cs index 7f7350466..06d1ed502 100644 --- a/src/TurboHTTP.AcceptanceTests/H2/RequestFormatSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H2/RequestFormatSpec.cs @@ -62,7 +62,7 @@ public async Task Get_request_should_contain_method_pseudo_header() var decoder = new HpackDecoder(); var headers = decoder.Decode(headersFrame.HeaderBlockFragment.Span); - Assert.Contains(headers, h => h.Name == ":method" && h.Value == "GET"); + Assert.Contains(headers, h => h is { Name: ":method", Value: "GET" }); } [Fact(Timeout = 5000)] @@ -81,7 +81,7 @@ public async Task Get_request_should_contain_path_pseudo_header() var decoder = new HpackDecoder(); var headers = decoder.Decode(headersFrame.HeaderBlockFragment.Span); - Assert.Contains(headers, h => h.Name == ":path" && h.Value == "/some/path"); + Assert.Contains(headers, h => h is { Name: ":path", Value: "/some/path" }); } [Fact(Timeout = 5000)] @@ -100,7 +100,7 @@ public async Task Get_request_should_contain_scheme_pseudo_header() var decoder = new HpackDecoder(); var headers = decoder.Decode(headersFrame.HeaderBlockFragment.Span); - Assert.Contains(headers, h => h.Name == ":scheme" && h.Value == "http"); + Assert.Contains(headers, h => h is { Name: ":scheme", Value: "http" }); } [Fact(Timeout = 5000)] @@ -119,7 +119,7 @@ public async Task Get_request_should_contain_authority_pseudo_header() var decoder = new HpackDecoder(); var headers = decoder.Decode(headersFrame.HeaderBlockFragment.Span); - Assert.Contains(headers, h => h.Name == ":authority" && h.Value == "example.com"); + Assert.Contains(headers, h => h is { Name: ":authority", Value: "example.com" }); } [Fact(Timeout = 5000)] @@ -177,7 +177,7 @@ public async Task Post_request_should_contain_method_pseudo_header() var decoder = new HpackDecoder(); var headers = decoder.Decode(headersFrame.HeaderBlockFragment.Span); - Assert.Contains(headers, h => h.Name == ":method" && h.Value == "POST"); + Assert.Contains(headers, h => h is { Name: ":method", Value: "POST" }); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.AcceptanceTests/H3/RequestFormatSpec.cs b/src/TurboHTTP.AcceptanceTests/H3/RequestFormatSpec.cs index d2242b92c..f34d52d53 100644 --- a/src/TurboHTTP.AcceptanceTests/H3/RequestFormatSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H3/RequestFormatSpec.cs @@ -45,10 +45,10 @@ public async Task Get_request_should_contain_method_pseudo_header() CreateHttp30Engine().CreateFlow(), request, ControlFrames(), ResponseFrames()); var headersFrame = outboundFrames.OfType().First(); - var decoder = new QpackDecoder(); + var decoder = new QpackDecoder(4096, 100); var headers = decoder.Decode(headersFrame.HeaderBlock.Span); - Assert.Contains(headers, h => h.Name == ":method" && h.Value == "GET"); + Assert.Contains(headers, h => h is { Name: ":method", Value: "GET" }); } [Fact(Timeout = 5000)] @@ -64,10 +64,10 @@ public async Task Get_request_should_contain_path_pseudo_header() CreateHttp30Engine().CreateFlow(), request, ControlFrames(), ResponseFrames()); var headersFrame = outboundFrames.OfType().First(); - var decoder = new QpackDecoder(); + var decoder = new QpackDecoder(4096, 100); var headers = decoder.Decode(headersFrame.HeaderBlock.Span); - Assert.Contains(headers, h => h.Name == ":path" && h.Value == "/some/path"); + Assert.Contains(headers, h => h is { Name: ":path", Value: "/some/path" }); } [Fact(Timeout = 5000)] @@ -83,10 +83,10 @@ public async Task Get_request_should_contain_scheme_pseudo_header() CreateHttp30Engine().CreateFlow(), request, ControlFrames(), ResponseFrames()); var headersFrame = outboundFrames.OfType().First(); - var decoder = new QpackDecoder(); + var decoder = new QpackDecoder(4096, 100); var headers = decoder.Decode(headersFrame.HeaderBlock.Span); - Assert.Contains(headers, h => h.Name == ":scheme" && h.Value == "http"); + Assert.Contains(headers, h => h is { Name: ":scheme", Value: "http" }); } [Fact(Timeout = 5000)] @@ -102,10 +102,10 @@ public async Task Get_request_should_contain_authority_pseudo_header() CreateHttp30Engine().CreateFlow(), request, ControlFrames(), ResponseFrames()); var headersFrame = outboundFrames.OfType().First(); - var decoder = new QpackDecoder(); + var decoder = new QpackDecoder(4096, 100); var headers = decoder.Decode(headersFrame.HeaderBlock.Span); - Assert.Contains(headers, h => h.Name == ":authority" && h.Value == "example.com"); + Assert.Contains(headers, h => h is { Name: ":authority", Value: "example.com" }); } [Fact(Timeout = 5000)] @@ -143,10 +143,10 @@ public async Task Post_request_should_contain_method_pseudo_header() CreateHttp30Engine().CreateFlow(), request, ControlFrames(), ResponseFrames()); var headersFrame = outboundFrames.OfType().First(); - var decoder = new QpackDecoder(); + var decoder = new QpackDecoder(4096, 100); var headers = decoder.Decode(headersFrame.HeaderBlock.Span); - Assert.Contains(headers, h => h.Name == ":method" && h.Value == "POST"); + Assert.Contains(headers, h => h is { Name: ":method", Value: "POST" }); } [Fact(Timeout = 5000)] @@ -163,7 +163,7 @@ public async Task Request_should_place_pseudo_headers_before_regular_headers() CreateHttp30Engine().CreateFlow(), request, ControlFrames(), ResponseFrames()); var headersFrame = outboundFrames.OfType().First(); - var decoder = new QpackDecoder(); + var decoder = new QpackDecoder(4096, 100); var headers = decoder.Decode(headersFrame.HeaderBlock.Span); var lastPseudoIndex = -1; diff --git a/src/TurboHTTP.AcceptanceTests/Shared/FakeProxyStageSpec.cs b/src/TurboHTTP.AcceptanceTests/Shared/FakeProxyStageSpec.cs index 8c6362118..489ccdf6f 100644 --- a/src/TurboHTTP.AcceptanceTests/Shared/FakeProxyStageSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/Shared/FakeProxyStageSpec.cs @@ -27,7 +27,7 @@ public async Task FakeProxy_should_respond_with_200_connection_established_when_ var items = new ITransportOutbound[] { MakeConnectTransport(), - new TransportData(requestBytes) + TransportData.Rent(requestBytes) }; var results = new List(); @@ -69,7 +69,7 @@ public async Task FakeProxy_should_expose_tunneled_request_bytes_via_channel() var items = new ITransportOutbound[] { MakeConnectTransport(), - new TransportData(requestBytes) + TransportData.Rent(requestBytes) }; var results = new List(); @@ -121,8 +121,8 @@ public async Task FakeProxy_should_abort_stream_when_factory_returns_null_after_ var items = new ITransportOutbound[] { MakeConnectTransport(), - new TransportData(firstRequest), - new TransportData(secondRequest) + TransportData.Rent(firstRequest), + TransportData.Rent(secondRequest) }; var results = new List(); diff --git a/src/TurboHTTP.AcceptanceTests/Shared/H3ResponseBuilderSpec.cs b/src/TurboHTTP.AcceptanceTests/Shared/H3ResponseBuilderSpec.cs index 2ec566c7f..29191cbcd 100644 --- a/src/TurboHTTP.AcceptanceTests/Shared/H3ResponseBuilderSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/Shared/H3ResponseBuilderSpec.cs @@ -31,10 +31,10 @@ public void Build_should_produce_valid_settings_headers_data_sequence() Assert.Equal(8192L, settings.Parameters[1].Value); var headers = Assert.IsType(frames[1]); - var qpackDecoder = new QpackDecoder(); + var qpackDecoder = new QpackDecoder(4096, 100); var decoded = qpackDecoder.Decode(headers.HeaderBlock.Span); - Assert.Contains(decoded, h => h.Name == ":status" && h.Value == "200"); - Assert.Contains(decoded, h => h.Name == "content-type" && h.Value == "text/plain"); + Assert.Contains(decoded, h => h is { Name: ":status", Value: "200" }); + Assert.Contains(decoded, h => h is { Name: "content-type", Value: "text/plain" }); var data = Assert.IsType(frames[2]); Assert.Equal("hello"u8.ToArray(), data.Data.ToArray()); @@ -86,7 +86,7 @@ public void Build_should_produce_headers_only_response() Assert.Single(frames); var headers = Assert.IsType(frames[0]); - var qpackDecoder = new QpackDecoder(); + var qpackDecoder = new QpackDecoder(4096, 100); var decoded = qpackDecoder.Decode(headers.HeaderBlock.Span); Assert.Single(decoded); Assert.Equal(":status", decoded[0].Name); diff --git a/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs b/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs index b98504c98..daa85ae19 100644 --- a/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs @@ -97,7 +97,7 @@ public async Task Connection_should_default_to_keep_alive_without_connection_hea Assert.Equal("default", body); } - [Fact(Timeout = 5000)] + [Fact(Timeout = 10000)] [Trait("RFC", "RFC9110-7.8")] public async Task Connection_101_switching_protocols_must_not_be_reusable_for_http() { diff --git a/src/TurboHTTP.AcceptanceTests/xunit.runner.json b/src/TurboHTTP.AcceptanceTests/xunit.runner.json index 73179ea81..c42f85d94 100644 --- a/src/TurboHTTP.AcceptanceTests/xunit.runner.json +++ b/src/TurboHTTP.AcceptanceTests/xunit.runner.json @@ -2,5 +2,5 @@ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", "parallelizeTestCollections": true, "parallelizeAssembly": false, - "maxParallelThreads": 4 + "maxParallelThreads": "0.5x" } diff --git a/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenHttpClientConcurrentBenchmarks.cs b/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenHttpClientConcurrentBenchmarks.cs deleted file mode 100644 index d2464e15b..000000000 --- a/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenHttpClientConcurrentBenchmarks.cs +++ /dev/null @@ -1,111 +0,0 @@ -using BenchmarkDotNet.Attributes; -using TurboHTTP.Benchmarks.Internal; - -namespace TurboHTTP.Benchmarks.Binkraken; - -/// -/// Baseline benchmarks measuring standard .NET performance -/// under concurrent load against Binkraken.com over HTTPS. -/// -[MemoryDiagnoser] -[WarmupCount(3)] -[IterationCount(10)] -public class BinkrakenHttpClientConcurrentBenchmarks : BinkrakenBaseClass -{ - private const int MaxFanOut = 1024; - - [Params(1, 512, 4096)] - public int ConcurrencyLevel { get; set; } - - private HttpClient _httpClient = null!; - private Task[] _tasks = null!; - private SemaphoreSlim _fanOutGate = null!; - - [GlobalSetup] - public override async Task GlobalSetup() - { - await base.GlobalSetup(); - - var handler = new SocketsHttpHandler - { - AllowAutoRedirect = false, - EnableMultipleHttp2Connections = true, - MaxConnectionsPerServer = 64, - }; - - _httpClient = new HttpClient(handler) - { - DefaultRequestVersion = HttpVersionValue, - DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, - }; - - _tasks = new Task[ConcurrencyLevel]; - _fanOutGate = new SemaphoreSlim(MaxFanOut, MaxFanOut); - await WarmupRequest(); - } - - [GlobalCleanup] - public override async Task GlobalCleanup() - { - _fanOutGate.Dispose(); - _httpClient.Dispose(); - await base.GlobalCleanup(); - } - - /// - public override async Task WarmupRequest() - { - using var response = await _httpClient.GetAsync(LightUri); - response.EnsureSuccessStatusCode(); - } - - [Benchmark] - public Task ConcurrentRequests_Light() - { - for (var i = 0; i < ConcurrencyLevel; i++) - { - _tasks[i] = SendLightRequest(); - } - - return Task.WhenAll(_tasks); - } - - [Benchmark] - public Task ConcurrentRequests_Heavy() - { - for (var i = 0; i < ConcurrencyLevel; i++) - { - _tasks[i] = SendHeavyRequest(); - } - - return Task.WhenAll(_tasks); - } - - private async Task SendLightRequest() - { - await _fanOutGate.WaitAsync(); - try - { - using var response = await _httpClient.GetAsync(LightUri); - response.EnsureSuccessStatusCode(); - } - finally - { - _fanOutGate.Release(); - } - } - - private async Task SendHeavyRequest() - { - await _fanOutGate.WaitAsync(); - try - { - using var response = await _httpClient.GetAsync(HeavyUri); - response.EnsureSuccessStatusCode(); - } - finally - { - _fanOutGate.Release(); - } - } -} diff --git a/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenTurboSendAsyncConcurrentBenchmarks.cs b/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenTurboSendAsyncConcurrentBenchmarks.cs deleted file mode 100644 index 788233f51..000000000 --- a/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenTurboSendAsyncConcurrentBenchmarks.cs +++ /dev/null @@ -1,105 +0,0 @@ -using TurboHTTP.Client; -using BenchmarkDotNet.Attributes; -using TurboHTTP.Benchmarks.Internal; - -namespace TurboHTTP.Benchmarks.Binkraken; - -/// -/// Benchmarks measuring performance using -/// under concurrent load against -/// Binkraken.com over HTTPS. -/// -[MemoryDiagnoser] -[WarmupCount(3)] -[IterationCount(10)] -public class BinkrakenTurboSendAsyncConcurrentBenchmarks : BinkrakenBaseClass -{ - private const int MaxFanOut = 1024; - - [Params(1, 512, 4096)] - public int ConcurrencyLevel { get; set; } - - private static readonly Uri BaseAddress = new("https://binkraken.com"); - - private ClientHelper _clientHelper = null!; - private Task[] _tasks = null!; - private SemaphoreSlim _fanOutGate = null!; - - [GlobalSetup] - public override async Task GlobalSetup() - { - await base.GlobalSetup(); - _clientHelper = ClientHelper.CreateClient(BaseAddress, HttpVersionValue); - _tasks = new Task[ConcurrencyLevel]; - _fanOutGate = new SemaphoreSlim(MaxFanOut, MaxFanOut); - await WarmupRequest(); - } - - [GlobalCleanup] - public override async Task GlobalCleanup() - { - _fanOutGate.Dispose(); - await _clientHelper.DisposeAsync(); - await base.GlobalCleanup(); - } - - /// - public override async Task WarmupRequest() - { - using var request = new HttpRequestMessage(HttpMethod.Get, LightUri); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); - response.EnsureSuccessStatusCode(); - } - - [Benchmark] - public Task ConcurrentRequests_Light() - { - for (var i = 0; i < ConcurrencyLevel; i++) - { - _tasks[i] = SendLightRequest(); - } - - return Task.WhenAll(_tasks); - } - - [Benchmark] - public Task ConcurrentRequests_Heavy() - { - for (var i = 0; i < ConcurrencyLevel; i++) - { - _tasks[i] = SendHeavyRequest(); - } - - return Task.WhenAll(_tasks); - } - - private async Task SendLightRequest() - { - await _fanOutGate.WaitAsync(); - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, LightUri); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); - response.EnsureSuccessStatusCode(); - } - finally - { - _fanOutGate.Release(); - } - } - - private async Task SendHeavyRequest() - { - await _fanOutGate.WaitAsync(); - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, HeavyUri); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); - response.EnsureSuccessStatusCode(); - } - finally - { - _fanOutGate.Release(); - } - } -} diff --git a/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenTurboStreamingConcurrentBenchmarks.cs b/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenTurboStreamingConcurrentBenchmarks.cs deleted file mode 100644 index 17ae95206..000000000 --- a/src/TurboHTTP.Benchmarks/Binkraken/BinkrakenTurboStreamingConcurrentBenchmarks.cs +++ /dev/null @@ -1,88 +0,0 @@ -using TurboHTTP.Client; -using BenchmarkDotNet.Attributes; -using TurboHTTP.Benchmarks.Internal; - -namespace TurboHTTP.Benchmarks.Binkraken; - -/// -/// Benchmarks measuring throughput using the channel-based -/// streaming API under concurrent load against Binkraken.com over HTTPS. -/// -[MemoryDiagnoser] -[WarmupCount(3)] -[IterationCount(10)] -public class BinkrakenTurboStreamingConcurrentBenchmarks : BinkrakenBaseClass -{ - [Params(1, 512, 4096)] - public int ConcurrencyLevel { get; set; } - - private static readonly Uri BaseAddress = new("https://binkraken.com"); - - private ClientHelper _clientHelper = null!; - - [GlobalSetup] - public override async Task GlobalSetup() - { - await base.GlobalSetup(); - _clientHelper = ClientHelper.CreateStreamingClient(BaseAddress, HttpVersionValue); - await WarmupRequest(); - } - - [GlobalCleanup] - public override async Task GlobalCleanup() - { - await _clientHelper.DisposeAsync(); - await base.GlobalCleanup(); - } - - /// - public override async Task WarmupRequest() - { - using var request = new HttpRequestMessage(HttpMethod.Get, LightUri); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); - response.EnsureSuccessStatusCode(); - } - - [Benchmark] - public async Task ConcurrentRequests_Light() - { - await StreamRequests(LightUri); - } - - [Benchmark] - public async Task ConcurrentRequests_Heavy() - { - await StreamRequests(HeavyUri); - } - - private async Task StreamRequests(Uri uri) - { - var client = _clientHelper.Client; - var count = ConcurrencyLevel; - - for (var i = 0; i < count; i++) - { - var request = new HttpRequestMessage(HttpMethod.Get, uri); - await client.Requests.WriteAsync(request); - } - - var received = 0; - while (received < count) - { - if (!await client.Responses.WaitToReadAsync()) - { - break; - } - - while (client.Responses.TryRead(out var response)) - { - response.Dispose(); - received++; - if (received >= count) - { - break; - } - } - } - } -} diff --git a/src/TurboHTTP.Benchmarks/Internal/BenchmarkComparisonReport.cs b/src/TurboHTTP.Benchmarks/Internal/BenchmarkComparisonReport.cs index eb1d131ad..c78d346e6 100644 --- a/src/TurboHTTP.Benchmarks/Internal/BenchmarkComparisonReport.cs +++ b/src/TurboHTTP.Benchmarks/Internal/BenchmarkComparisonReport.cs @@ -37,9 +37,9 @@ public static string GenerateReport( IReadOnlyList turboStreamingResults) { var sb = new StringBuilder(); - AppendBinkrakenHeader(sb, DateTime.UtcNow); + AppendKestrelClientHeader(sb, DateTime.UtcNow); AppendVersionSections(sb, httpClientResults, turboSendAsyncResults, turboStreamingResults); - AppendBinkrakenNotes(sb); + AppendKestrelClientNotes(sb); return sb.ToString(); } @@ -78,13 +78,13 @@ public static string GenerateServerReport( /// Writes a markdown report to benchmarks/comparison_report_{timestamp}.md /// relative to the current working directory, creating the directory if needed. /// - public static string WriteReportToFile(string markdown) + public static string WriteReportToFile(string markdown, string reportName = "comparison") { var outputDir = Path.Combine(Directory.GetCurrentDirectory(), "benchmarks"); Directory.CreateDirectory(outputDir); var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); - var filePath = Path.Combine(outputDir, $"comparison_report_{timestamp}.md"); + var filePath = Path.Combine(outputDir, $"{reportName}_{timestamp}.md"); File.WriteAllText(filePath, markdown, Encoding.UTF8); return filePath; @@ -132,17 +132,17 @@ private static void AppendVersionSections( } } - private static void AppendBinkrakenHeader(StringBuilder sb, DateTime reportDate) + private static void AppendKestrelClientHeader(StringBuilder sb, DateTime reportDate) { - sb.AppendLine("# TurboHttp vs HttpClient — Binkraken.com (Remote HTTPS)"); + sb.AppendLine("# TurboHttp vs HttpClient — Kestrel Localhost"); sb.AppendLine(); sb.AppendLine("| | |"); sb.AppendLine("|---|---|"); sb.AppendLine($"| **Report date** | {reportDate:yyyy-MM-dd HH:mm} UTC |"); - sb.AppendLine("| **Server** | binkraken.com (GitHub Pages CDN) |"); - sb.AppendLine("| **Protocol** | HTTPS — HTTP/1.1, HTTP/2 (ALPN), HTTP/3 (QUIC) |"); - sb.AppendLine("| **Light endpoint** | `GET /` (~3 KB HTML) |"); - sb.AppendLine("| **Heavy endpoint** | `GET /assets/…plugin-vue_export-helper….js` (~159 KB) |"); + sb.AppendLine("| **Server** | Kestrel (localhost) |"); + sb.AppendLine("| **Protocol** | HTTP/1.1 cleartext, HTTP/2 (h2c), HTTP/3 (QUIC+TLS) |"); + sb.AppendLine("| **Light endpoint** | `GET /plaintext` (~13 bytes) |"); + sb.AppendLine("| **Heavy endpoint** | `POST /upload` (1 MB payload) |"); sb.AppendLine(); sb.AppendLine("> **Legend:**"); sb.AppendLine("> - ✓ faster than HttpClient by >5%"); @@ -152,15 +152,12 @@ private static void AppendBinkrakenHeader(StringBuilder sb, DateTime reportDate) sb.AppendLine(); } - private static void AppendBinkrakenNotes(StringBuilder sb) + private static void AppendKestrelClientNotes(StringBuilder sb) { sb.AppendLine("## Notes"); sb.AppendLine(); - sb.AppendLine("- All requests target binkraken.com over real internet (HTTPS/TLS)."); - sb.AppendLine("- Results include DNS resolution, TLS handshake (first request), and network latency."); - sb.AppendLine("- Light: `GET /` returns the SPA index (~3 KB). Heavy: `GET /assets/…` returns a JS bundle (~159 KB)."); - sb.AppendLine("- HTTP/2 is negotiated via ALPN over TLS — no cleartext h2c. HTTP/3 uses QUIC when server supports Alt-Svc."); - sb.AppendLine("- Variance may be higher than loopback benchmarks due to network jitter and CDN caching."); + sb.AppendLine("- All requests target localhost Kestrel — results reflect pure client overhead."); + sb.AppendLine("- HTTP/1.1 and HTTP/2 use cleartext (no TLS). HTTP/3 uses QUIC+TLS with a self-signed certificate."); sb.AppendLine("- Memory figures reflect managed allocations only; native/pooled buffers are not included."); sb.AppendLine("- **Streaming** uses the channel API (`Requests` writer / `Responses` reader)."); sb.AppendLine("- **SendAsync** uses `Task.WhenAll` fan-out; each concurrent slot gets its own `Task`."); diff --git a/src/TurboHTTP.Benchmarks/Internal/BenchmarkServer.cs b/src/TurboHTTP.Benchmarks/Internal/BenchmarkServer.cs index 24208cd50..ffe030796 100644 --- a/src/TurboHTTP.Benchmarks/Internal/BenchmarkServer.cs +++ b/src/TurboHTTP.Benchmarks/Internal/BenchmarkServer.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Transport.Quic; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -51,12 +52,19 @@ public async ValueTask InitializeAsync() options.Limits.Http2.MaxStreamsPerConnection = 512; options.Limits.Http2.InitialConnectionWindowSize = 4 * 1024 * 1024; options.Limits.Http2.InitialStreamWindowSize = 1024 * 1024; + options.Limits.Http2.MaxFrameSize = 256 * 1024; // Raise general limits for HTTP/3 high-concurrency benchmarks. options.Limits.MaxConcurrentConnections = null; options.Limits.MaxConcurrentUpgradedConnections = null; }); + builder.WebHost.UseQuic(quic => + { + quic.MaxBidirectionalStreamCount = 512; + quic.MaxUnidirectionalStreamCount = 32; + }); + var app = builder.Build(); RegisterRoutes(app); diff --git a/src/TurboHTTP.Benchmarks/Internal/BenchmarkSuiteBase.cs b/src/TurboHTTP.Benchmarks/Internal/BenchmarkSuiteBase.cs index ffb6b4e4e..e46b0d255 100644 --- a/src/TurboHTTP.Benchmarks/Internal/BenchmarkSuiteBase.cs +++ b/src/TurboHTTP.Benchmarks/Internal/BenchmarkSuiteBase.cs @@ -3,7 +3,7 @@ namespace TurboHTTP.Benchmarks.Internal; /// -/// Common base class shared by all benchmark suites (Binkraken remote and Kestrel localhost). +/// Common base class shared by all benchmark suites (Kestrel and TurboServer localhost). /// Provides BenchmarkDotNet parameter sets (concurrency level, HTTP version), ThreadPool /// tuning, and the warm-up hook. Subclasses add environment-specific setup (server lifecycle, /// URI construction, payload helpers). @@ -11,7 +11,6 @@ namespace TurboHTTP.Benchmarks.Internal; [Config(typeof(EngineBenchmarkConfig))] public abstract class BenchmarkSuiteBase { - /// HTTP protocol version: "1.1", "2.0", or "3.0". [Params("1.1", "2.0", "3.0")] public string HttpVersion { get; set; } = "1.1"; diff --git a/src/TurboHTTP.Benchmarks/Internal/BinkrakenBaseClass.cs b/src/TurboHTTP.Benchmarks/Internal/BinkrakenBaseClass.cs deleted file mode 100644 index ae80e8189..000000000 --- a/src/TurboHTTP.Benchmarks/Internal/BinkrakenBaseClass.cs +++ /dev/null @@ -1,22 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace TurboHTTP.Benchmarks.Internal; - -/// -/// Base class for all Binkraken remote HTTPS benchmarks. Provides static URIs -/// for the light (~3 KB HTML) and heavy (~129 KB JS bundle) endpoints. -/// -public abstract class BinkrakenBaseClass : BenchmarkSuiteBase -{ - [Params("1.1", "2.0")] - public new string HttpVersion { get; set; } = "1.1"; - /// - /// Light endpoint: the SPA index page (~3 KB HTML). - /// - public static readonly Uri LightUri = new("https://binkraken.com/"); - - /// - /// Heavy endpoint: the largest JS bundle (~129 KB). - /// - public static readonly Uri HeavyUri = new("https://binkraken.com/assets/useBlog-CU_ZN4Zc.js"); -} diff --git a/src/TurboHTTP.Benchmarks/Internal/ClientHelper.cs b/src/TurboHTTP.Benchmarks/Internal/ClientHelper.cs index 2c65d19e5..79f29db06 100644 --- a/src/TurboHTTP.Benchmarks/Internal/ClientHelper.cs +++ b/src/TurboHTTP.Benchmarks/Internal/ClientHelper.cs @@ -29,7 +29,7 @@ private ClientHelper(ServiceProvider provider, ITurboHttpClient client, ActorSys /// /// Creates a new with a fully configured TurboHttp client - /// targeting a remote URI (e.g. https://binkraken.com) for SendAsync benchmarks. + /// targeting the benchmark server for SendAsync benchmarks. /// /// The remote base URI (scheme + host). /// The HTTP version to use. @@ -39,30 +39,35 @@ public static ClientHelper CreateClient(Uri baseAddress, Version version) { BaseAddress = baseAddress, DangerousAcceptAnyServerCertificate = true, + RequestBodyChunkSize = 64 * 1024, // H1.x: many connections with shallow pipelining to handle CL up to 8192. - Http1 = new Http1Options + Http1 = new Http1ClientOptions { MaxConnectionsPerServer = 512, MaxPipelineDepth = 64 }, - // H2: 16 connections × 1000 streams = 16 000 in-flight capacity. - Http2 = new Http2Options + // H2: 16 connections × 512 streams = 8192 in-flight capacity. + // MaxConcurrentStreams must not exceed Kestrel's MaxStreamsPerConnection (512). + Http2 = new Http2ClientOptions { MaxConnectionsPerServer = 16, - MaxConcurrentStreams = 1000 + MaxConcurrentStreams = 512, + MaxBufferedRequestBodySize = 2 * 1024 * 1024, }, - // H3: 8 connections × 1000 streams = 8000 in-flight capacity. - // QPACK dynamic table at 32 KiB for better header compression on repeated requests. - Http3 = new Http3Options + // H3: 64 connections × 100 streams = 6400 in-flight capacity. + // MaxConcurrentStreams must match Kestrel's default (100) — exceeding it blocks + // on QuicConnection.OpenOutboundStreamAsync until a stream is released. + Http3 = new Http3ClientOptions { - MaxConnectionsPerServer = 8, - MaxConcurrentStreams = 1000, + MaxConnectionsPerServer = 64, + MaxConcurrentStreams = 100, QpackMaxTableCapacity = 32_768, QpackBlockedStreams = 200, MaxFieldSectionSize = 65_536, IdleTimeout = TimeSpan.FromMinutes(5), MaxReconnectAttempts = 10, MaxReconnectBufferSize = 256, + MaxBufferedRequestBodySize = 2 * 1024 * 1024, }, }; @@ -81,15 +86,16 @@ public static ClientHelper CreateStreamingClient(Uri baseAddress, Version versio { BaseAddress = baseAddress, DangerousAcceptAnyServerCertificate = true, - // Streaming: fewer connections but deep pipelining via the channel. - Http1 = new Http1Options { MaxConnectionsPerServer = 4, MaxPipelineDepth = 2 * 1024 }, + // Streaming H1.x: enough connections to saturate high-CL scenarios + // (H1.1 is head-of-line blocked per connection, so depth alone doesn't help). + Http1 = new Http1ClientOptions { MaxConnectionsPerServer = 128, MaxPipelineDepth = 64 }, // H2: 16 connections × 1000 streams for high-CL streaming. - Http2 = new Http2Options { MaxConnectionsPerServer = 16, MaxConcurrentStreams = 1000 }, - // H3: 8 connections × 1000 streams, larger QPACK table for repeated header patterns. - Http3 = new Http3Options + Http2 = new Http2ClientOptions { MaxConnectionsPerServer = 16, MaxConcurrentStreams = 1000 }, + // H3: 64 connections × 100 streams — match Kestrel's MaxInboundBidirectionalStreams default. + Http3 = new Http3ClientOptions { - MaxConnectionsPerServer = 8, - MaxConcurrentStreams = 1000, + MaxConnectionsPerServer = 64, + MaxConcurrentStreams = 100, QpackMaxTableCapacity = 32_768, QpackBlockedStreams = 200, MaxFieldSectionSize = 65_536, @@ -97,7 +103,7 @@ public static ClientHelper CreateStreamingClient(Uri baseAddress, Version versio MaxReconnectAttempts = 10, MaxReconnectBufferSize = 256, }, - MaxEndpointSubstreams = 16384, + MaxConcurrentEndpoints = 16384, }; return Build(baseAddress, version, options); diff --git a/src/TurboHTTP.Benchmarks/Internal/Config.cs b/src/TurboHTTP.Benchmarks/Internal/Config.cs index 814fbcbdb..39fc30933 100644 --- a/src/TurboHTTP.Benchmarks/Internal/Config.cs +++ b/src/TurboHTTP.Benchmarks/Internal/Config.cs @@ -3,6 +3,7 @@ using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; @@ -57,6 +58,64 @@ public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyl } } +/// +/// Re-emits the BenchmarkDotNet summary table to the console with ANSI color codes applied +/// per HTTP version row: cyan = HTTP/1.1, green = HTTP/2.0, yellow = HTTP/3.0. +/// +public sealed class HttpVersionColorExporter : IExporter +{ + public static readonly HttpVersionColorExporter Default = new(); + + public string Name => nameof(HttpVersionColorExporter); + + public IEnumerable ExportToFiles(Summary summary, ILogger consoleLogger) + => []; + + public void ExportToLog(Summary summary, ILogger logger) + { + var capture = new CaptureLogger(); + MarkdownExporter.GitHub.ExportToLog(summary, capture); + + foreach (var line in capture.GetLines()) + { + var ansi = VersionAnsi(line); + if (ansi != null) + logger.Write(LogKind.Default, ansi + line + "\x1b[0m\n"); + else + logger.WriteLine(LogKind.Default, line); + } + } + + private static string? VersionAnsi(string line) + { + if (line.Length == 0 || line[0] != '|') + { + return null; + } + + if (line.Contains("| 1.1 |", StringComparison.Ordinal)) return "\x1b[36m"; // cyan + if (line.Contains("| 2.0 |", StringComparison.Ordinal)) return "\x1b[32m"; // green + if (line.Contains("| 3.0 |", StringComparison.Ordinal)) return "\x1b[33m"; // yellow + return null; + } + + private sealed class CaptureLogger : ILogger + { + private readonly System.Text.StringBuilder _sb = new(); + + public string Id => nameof(CaptureLogger); + public int Priority => 0; + + public void Write(LogKind logKind, string text) => _sb.Append(text); + public void WriteLine(LogKind logKind, string text) => _sb.Append(text).Append('\n'); + public void WriteLine() => _sb.Append('\n'); + public void Flush() { } + + public IEnumerable GetLines() + => _sb.ToString().Split('\n').Select(l => l.TrimEnd('\r')); + } +} + /// /// Benchmark configuration for engine-level throughput and latency measurements. /// Includes p50/p95/p100 latency percentile columns, memory diagnostics, and a @@ -66,9 +125,14 @@ public class EngineBenchmarkConfig : ManualConfig { public EngineBenchmarkConfig() { + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + var artifactsPath = Path.Combine("BenchmarkDotNet.Artifacts", timestamp); + + WithArtifactsPath(artifactsPath); AddJob(Job.Default.WithGcServer(true)); AddDiagnoser(MemoryDiagnoser.Default); AddExporter(MarkdownExporter.GitHub); + AddExporter(HttpVersionColorExporter.Default); AddColumn(StatisticColumn.P50); AddColumn(StatisticColumn.P95); AddColumn(StatisticColumn.P100); diff --git a/src/TurboHTTP.Benchmarks/Internal/TurboServerBaseClass.cs b/src/TurboHTTP.Benchmarks/Internal/TurboServerBaseClass.cs index 780a1c185..ac8c32e6e 100644 --- a/src/TurboHTTP.Benchmarks/Internal/TurboServerBaseClass.cs +++ b/src/TurboHTTP.Benchmarks/Internal/TurboServerBaseClass.cs @@ -3,7 +3,7 @@ namespace TurboHTTP.Benchmarks.Internal; public abstract class TurboServerBaseClass : BenchmarkSuiteBase { private static TurboBenchmarkServer? _sharedServer; - private static readonly SemaphoreSlim _serverLock = new(1, 1); + private static readonly SemaphoreSlim ServerLock = new(1, 1); private static int _serverRefCount; protected static readonly byte[] HeavyPayload = GeneratePayload(1 * 1024 * 1024); @@ -41,7 +41,7 @@ public override async Task GlobalSetup() { await base.GlobalSetup(); - await _serverLock.WaitAsync(); + await ServerLock.WaitAsync(); try { if (_sharedServer is null) @@ -57,13 +57,13 @@ public override async Task GlobalSetup() } finally { - _serverLock.Release(); + ServerLock.Release(); } } public override async Task GlobalCleanup() { - await _serverLock.WaitAsync(); + await ServerLock.WaitAsync(); try { _serverRefCount--; @@ -75,7 +75,7 @@ public override async Task GlobalCleanup() } finally { - _serverLock.Release(); + ServerLock.Release(); } } } diff --git a/src/TurboHTTP.Benchmarks/Kestrel/KestrelHttpClientConcurrentBenchmarks.cs b/src/TurboHTTP.Benchmarks/Kestrel/KestrelHttpClientConcurrentBenchmarks.cs index d286ae3b8..e8dc91b06 100644 --- a/src/TurboHTTP.Benchmarks/Kestrel/KestrelHttpClientConcurrentBenchmarks.cs +++ b/src/TurboHTTP.Benchmarks/Kestrel/KestrelHttpClientConcurrentBenchmarks.cs @@ -40,6 +40,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -63,25 +64,25 @@ public override async Task WarmupRequest() } [Benchmark] - public Task ConcurrentRequests_Light() + public async Task ConcurrentRequests_Light() { for (var i = 0; i < ConcurrencyLevel; i++) { _tasks[i] = SendLightRequest(); } - return Task.WhenAll(_tasks); + await Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } [Benchmark] - public Task ConcurrentRequests_Heavy() + public async Task ConcurrentRequests_Heavy() { for (var i = 0; i < ConcurrencyLevel; i++) { _tasks[i] = SendHeavyRequest(); } - return Task.WhenAll(_tasks); + await Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendLightRequest() @@ -104,7 +105,7 @@ private async Task SendHeavyRequest() try { using var content = new ByteArrayContent(HeavyPayload); - using var response = await _httpClient.PostAsync(HeavyUri, content); + using var response = await _httpClient.PostAsync(UploadUri, content); response.EnsureSuccessStatusCode(); } finally diff --git a/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboSendAsyncConcurrentBenchmarks.cs b/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboSendAsyncConcurrentBenchmarks.cs index 9b3f02660..8956a1b91 100644 --- a/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboSendAsyncConcurrentBenchmarks.cs +++ b/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboSendAsyncConcurrentBenchmarks.cs @@ -14,8 +14,6 @@ namespace TurboHTTP.Benchmarks.Kestrel; [IterationCount(10)] public class KestrelTurboSendAsyncConcurrentBenchmarks : KestrelBaseClass { - private const int MaxFanOut = 1024; - [Params(1, 512, 4096)] public int ConcurrencyLevel { get; set; } @@ -23,6 +21,20 @@ public class KestrelTurboSendAsyncConcurrentBenchmarks : KestrelBaseClass private Task[] _tasks = null!; private SemaphoreSlim _fanOutGate = null!; + private int MaxFanOut => HttpVersion switch + { + "2.0" => 256, + "3.0" => 256, + _ => 512, + }; + + private TimeSpan BenchmarkTimeout => ConcurrencyLevel switch + { + >= 4096 => TimeSpan.FromSeconds(120), + >= 512 => TimeSpan.FromSeconds(60), + _ => TimeSpan.FromSeconds(30), + }; + [GlobalSetup] public override async Task GlobalSetup() { @@ -44,8 +56,9 @@ public override async Task GlobalCleanup() /// public override async Task WarmupRequest() { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var request = new HttpRequestMessage(HttpMethod.Get, LightUri); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); + using var response = await _clientHelper.Client.SendAsync(request, cts.Token); response.EnsureSuccessStatusCode(); } @@ -57,7 +70,7 @@ public Task ConcurrentRequests_Light() _tasks[i] = SendLightRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(BenchmarkTimeout); } [Benchmark] @@ -68,7 +81,7 @@ public Task ConcurrentRequests_Heavy() _tasks[i] = SendHeavyRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(BenchmarkTimeout); } private async Task SendLightRequest() @@ -76,8 +89,9 @@ private async Task SendLightRequest() await _fanOutGate.WaitAsync(); try { + using var cts = new CancellationTokenSource(BenchmarkTimeout); using var request = new HttpRequestMessage(HttpMethod.Get, LightUri); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); + using var response = await _clientHelper.Client.SendAsync(request, cts.Token); response.EnsureSuccessStatusCode(); } finally @@ -91,9 +105,10 @@ private async Task SendHeavyRequest() await _fanOutGate.WaitAsync(); try { - using var request = new HttpRequestMessage(HttpMethod.Post, HeavyUri); + using var cts = new CancellationTokenSource(BenchmarkTimeout); + using var request = new HttpRequestMessage(HttpMethod.Post, UploadUri); request.Content = new ByteArrayContent(HeavyPayload); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); + using var response = await _clientHelper.Client.SendAsync(request, cts.Token); response.EnsureSuccessStatusCode(); } finally diff --git a/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboStreamingConcurrentBenchmarks.cs b/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboStreamingConcurrentBenchmarks.cs index c7f5332c3..fb060d94d 100644 --- a/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboStreamingConcurrentBenchmarks.cs +++ b/src/TurboHTTP.Benchmarks/Kestrel/KestrelTurboStreamingConcurrentBenchmarks.cs @@ -4,17 +4,12 @@ namespace TurboHTTP.Benchmarks.Kestrel; -/// -/// Benchmarks measuring throughput using the channel-based -/// streaming API under concurrent load against a localhost Kestrel server. -/// [MemoryDiagnoser] [WarmupCount(3)] [IterationCount(10)] public class KestrelTurboStreamingConcurrentBenchmarks : KestrelBaseClass { - [Params(1, 512, 4096)] - public int ConcurrencyLevel { get; set; } + [Params(1, 512, 4096)] public int ConcurrencyLevel { get; set; } private ClientHelper _clientHelper = null!; @@ -33,11 +28,27 @@ public override async Task GlobalCleanup() await base.GlobalCleanup(); } - /// + [IterationCleanup] + public void DrainResponses() + { + try + { + while (_clientHelper.Client.Responses.TryRead(out var stale)) + { + stale.Dispose(); + } + } + catch + { + // Channel may be in a faulted state — ignore during cleanup. + } + } + public override async Task WarmupRequest() { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var request = new HttpRequestMessage(HttpMethod.Get, LightUri); - using var response = await _clientHelper.Client.SendAsync(request, CancellationToken.None); + using var response = await _clientHelper.Client.SendAsync(request, cts.Token); response.EnsureSuccessStatusCode(); } @@ -50,42 +61,75 @@ public async Task ConcurrentRequests_Light() [Benchmark] public async Task ConcurrentRequests_Heavy() { - await StreamRequests(HeavyUri, HttpMethod.Post); + await StreamRequests(UploadUri, HttpMethod.Post); } private async Task StreamRequests(Uri uri, HttpMethod method) { var client = _clientHelper.Client; var count = ConcurrencyLevel; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var ct = cts.Token; - for (var i = 0; i < count; i++) + // Drain stale responses from prior iterations before starting + while (client.Responses.TryRead(out var stale)) { - var request = new HttpRequestMessage(method, uri); - if (method == HttpMethod.Post) - { - request.Content = new ByteArrayContent(HeavyPayload); - } - - await client.Requests.WriteAsync(request); + stale.Dispose(); } - var received = 0; - while (received < count) + // Cap in-flight requests to avoid unbounded memory growth at high CL. + // 512 matches Kestrel's MaxStreamsPerConnection — more just queues. + using var throttle = new SemaphoreSlim(Math.Min(count, 512)); + + try { - if (!await client.Responses.WaitToReadAsync()) + var writer = Task.Run(async () => { - break; - } + for (var i = 0; i < count; i++) + { + await throttle.WaitAsync(ct); + var request = new HttpRequestMessage(method, uri); + if (method == HttpMethod.Post) + { + request.Content = new ByteArrayContent(HeavyPayload); + } - while (client.Responses.TryRead(out var response)) + await client.Requests.WriteAsync(request, ct); + } + }, ct); + + var received = 0; + while (received < count) { - response.Dispose(); - received++; - if (received >= count) + if (!await client.Responses.WaitToReadAsync(ct)) { break; } + + while (client.Responses.TryRead(out var response)) + { + try + { + await response.Content.ReadAsByteArrayAsync(ct).WaitAsync(ct); + } + catch (OperationCanceledException) + { + } + + response.Dispose(); + throttle.Release(); + received++; + if (received >= count) + { + break; + } + } } + + await writer.WaitAsync(ct); + } + catch (OperationCanceledException) + { } } } diff --git a/src/TurboHTTP.Benchmarks/Program.cs b/src/TurboHTTP.Benchmarks/Program.cs index f7d8f5677..23a7fb753 100644 --- a/src/TurboHTTP.Benchmarks/Program.cs +++ b/src/TurboHTTP.Benchmarks/Program.cs @@ -1,5 +1,4 @@ using BenchmarkDotNet.Running; -using TurboHTTP.Benchmarks.Binkraken; using TurboHTTP.Benchmarks.Internal; using TurboHTTP.Benchmarks.Kestrel; @@ -7,36 +6,6 @@ var enumerable = summaries.ToList(); -var binkHttp = enumerable.FirstOrDefault(s => s.HasBenchmarksOf()); -var binkTurboSend = enumerable.FirstOrDefault(s => s.HasBenchmarksOf()); -var binkTurboStream = enumerable.FirstOrDefault(s => s.HasBenchmarksOf()); - -if (binkHttp is not null - && binkTurboSend is not null - && binkTurboStream is not null) -{ - var markdown = BenchmarkComparisonReport.GenerateReport( - SummaryExtractor.Extract(binkHttp), - SummaryExtractor.Extract(binkTurboSend), - SummaryExtractor.Extract(binkTurboStream)); - - if (markdown.Contains("NaN") || markdown.Contains("Infinity") || markdown.Contains("Inf%")) - { - Console.Error.WriteLine("WARNING: Binkraken report contains NaN or Inf values — check input data."); - } - - var path = BenchmarkComparisonReport.WriteReportToFile(markdown); - Console.WriteLine($"Binkraken comparison report: {path}"); -} -else -{ - Console.WriteLine("Binkraken comparison report skipped — not all 3 benchmark suites ran."); - Console.WriteLine("Required Binkraken suites:"); - Console.WriteLine($" BinkrakenHttpClientConcurrentBenchmarks : {(binkHttp is not null ? "OK" : "MISSING")}"); - Console.WriteLine($" BinkrakenTurboSendAsyncConcurrentBenchmarks : {(binkTurboSend is not null ? "OK" : "MISSING")}"); - Console.WriteLine($" BinkrakenTurboStreamingConcurrentBenchmarks : {(binkTurboStream is not null ? "OK" : "MISSING")}"); -} - var kestrelHttp = enumerable.FirstOrDefault(s => s.HasBenchmarksOf()); var kestrelTurboSend = enumerable.FirstOrDefault(s => s.HasBenchmarksOf()); var kestrelTurboStream = enumerable.FirstOrDefault(s => s.HasBenchmarksOf()); @@ -55,7 +24,7 @@ Console.Error.WriteLine("WARNING: Kestrel report contains NaN or Inf values — check input data."); } - var path = BenchmarkComparisonReport.WriteReportToFile(markdown); + var path = BenchmarkComparisonReport.WriteReportToFile(markdown, "kestrel_client"); Console.WriteLine($"Kestrel comparison report: {path}"); } else @@ -112,7 +81,7 @@ kestrelServerFortunes is not null || turboServerFortunes is not null || Console.Error.WriteLine("WARNING: Server report contains NaN or Inf values — check input data."); } - var serverPath = BenchmarkComparisonReport.WriteReportToFile(serverMarkdown); + var serverPath = BenchmarkComparisonReport.WriteReportToFile(serverMarkdown, "server"); Console.WriteLine($"Server comparison report: {serverPath}"); } else diff --git a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerFortunesBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerFortunesBenchmark.cs index a1e0f8b13..6765f58f7 100644 --- a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerFortunesBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerFortunesBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -72,7 +73,7 @@ public Task Fortunes_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerJsonBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerJsonBenchmark.cs index 7bb837883..2dce74d89 100644 --- a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerJsonBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerJsonBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -72,7 +73,7 @@ public Task Json_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerPlaintextBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerPlaintextBenchmark.cs index 4bb698dd0..42e62db46 100644 --- a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerPlaintextBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerPlaintextBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -72,7 +73,7 @@ public Task Plaintext_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerUploadBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerUploadBenchmark.cs index ab7696f0b..d307d9472 100644 --- a/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerUploadBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Kestrel/KestrelServerUploadBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -74,7 +75,7 @@ public Task Upload_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerFortunesBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerFortunesBenchmark.cs index be0c3472b..19afe3aa6 100644 --- a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerFortunesBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerFortunesBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -72,7 +73,7 @@ public Task Fortunes_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerJsonBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerJsonBenchmark.cs index 70348c454..644ef9a69 100644 --- a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerJsonBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerJsonBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -72,7 +73,7 @@ public Task Json_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerPlaintextBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerPlaintextBenchmark.cs index 339112998..fca164d0e 100644 --- a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerPlaintextBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerPlaintextBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -72,7 +73,7 @@ public Task Plaintext_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerUploadBenchmark.cs b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerUploadBenchmark.cs index cbfe2c99a..41523f7ee 100644 --- a/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerUploadBenchmark.cs +++ b/src/TurboHTTP.Benchmarks/Server/Turbo/TurboServerUploadBenchmark.cs @@ -36,6 +36,7 @@ public override async Task GlobalSetup() { DefaultRequestVersion = HttpVersionValue, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(30), }; _tasks = new Task[ConcurrencyLevel]; @@ -74,7 +75,7 @@ public Task Upload_Concurrent() { _tasks[i] = SendRequest(); } - return Task.WhenAll(_tasks); + return Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(30)); } private async Task SendRequest() diff --git a/src/TurboHTTP.Benchmarks/TurboHTTP.Benchmarks.csproj b/src/TurboHTTP.Benchmarks/TurboHTTP.Benchmarks.csproj index 70a39a0ac..59c8e97b2 100644 --- a/src/TurboHTTP.Benchmarks/TurboHTTP.Benchmarks.csproj +++ b/src/TurboHTTP.Benchmarks/TurboHTTP.Benchmarks.csproj @@ -3,8 +3,10 @@ Exe true + true true false + true diff --git a/src/TurboHTTP.IntegrationTests.Client/Client/CancellationSpec.cs b/src/TurboHTTP.IntegrationTests.Client/Client/CancellationSpec.cs new file mode 100644 index 000000000..c88df28b1 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Client/Client/CancellationSpec.cs @@ -0,0 +1,147 @@ +using System.Net; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.Client.Shared; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.IntegrationTests.Client.Client; + +[Collection("Cancellation")] +public sealed class CancellationSpec : IntegrationSpecBase +{ + public CancellationSpec(ServerContainerFixture server, ActorSystemFixture systemFixture) + : base(server, systemFixture) + { + } + + private static readonly ProtocolVariant H2Tls = new(TestHttpVersion.H2, tls: true); + private static readonly ProtocolVariant H11 = new(TestHttpVersion.H11, tls: false); + private static readonly ProtocolVariant H3Tls = new(TestHttpVersion.H3, tls: true); + + [Theory(Timeout = 10000)] + [InlineData("H2")] + [InlineData("H11")] + [InlineData("H3")] + public async Task SendAsync_cancelled_by_user_should_throw_OperationCanceledException(string proto) + { + var variant = ResolveVariant(proto); + await using var helper = CreateClient(variant); + var client = helper.Client; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + await Assert.ThrowsAnyAsync(async () => + { + await client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/delay/10"), cts.Token); + }); + } + + [Theory(Timeout = 15000)] + [InlineData("H2")] + [InlineData("H11")] + [InlineData("H3")] + public async Task SendAsync_cancelled_should_not_break_subsequent_requests(string proto) + { + var variant = ResolveVariant(proto); + await using var helper = CreateClient(variant); + var client = helper.Client; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + await Assert.ThrowsAnyAsync(async () => + { + await client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/delay/10"), cts.Token); + }); + + var response = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/get"), CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory(Timeout = 15000)] + [InlineData("H2")] + [InlineData("H11")] + [InlineData("H3")] + public async Task Timeout_should_cancel_and_allow_reuse(string proto) + { + var variant = ResolveVariant(proto); + await using var helper = CreateClient(variant); + var client = helper.Client; + client.Timeout = TimeSpan.FromMilliseconds(500); + + await Assert.ThrowsAnyAsync(async () => + { + await client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/delay/10"), CancellationToken.None); + }); + + client.Timeout = TimeSpan.FromMinutes(5); + + var response = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/get"), CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory(Timeout = 15000)] + [InlineData("H2")] + [InlineData("H11")] + [InlineData("H3")] + public async Task CancelPendingRequests_should_cancel_inflight_and_allow_reuse(string proto) + { + var variant = ResolveVariant(proto); + await using var helper = CreateClient(variant); + var client = helper.Client; + + var slowTask = client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/delay/10"), CancellationToken); + + await Task.Delay(200, CancellationToken); + client.CancelPendingRequests(); + + await Assert.ThrowsAnyAsync(async () => + { + await slowTask; + }); + + var response = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/get"), CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory(Timeout = 15000)] + [InlineData("H2")] + [InlineData("H3")] + public async Task Channel_path_with_timeout_should_cancel_and_allow_reuse(string proto) + { + var variant = ResolveVariant(proto); + await using var helper = CreateClient(variant); + var client = helper.Client; + + var request = new HttpRequestMessage(HttpMethod.Get, "/delay/10") + .WithTimeout(TimeSpan.FromMilliseconds(500)); + + var responseTask = request.GetResponseAsync(CancellationToken); + await client.Requests.WriteAsync(request, CancellationToken); + + await Assert.ThrowsAnyAsync(async () => + { + await responseTask; + }); + + var fast = new HttpRequestMessage(HttpMethod.Get, "/get"); + var fastResponse = await client.SendAsync(fast, CancellationToken); + Assert.Equal(HttpStatusCode.OK, fastResponse.StatusCode); + } + + private static ProtocolVariant ResolveVariant(string proto) => proto switch + { + "H2" => H2Tls, + "H11" => H11, + "H3" => H3Tls, + _ => throw new ArgumentException(proto) + }; +} diff --git a/src/TurboHTTP.IntegrationTests.Client/Features/SseFeatureSpec.cs b/src/TurboHTTP.IntegrationTests.Client/Features/SseFeatureSpec.cs index 9fd400dba..cc60f0459 100644 --- a/src/TurboHTTP.IntegrationTests.Client/Features/SseFeatureSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Client/Features/SseFeatureSpec.cs @@ -83,7 +83,11 @@ public async Task Sse_should_have_incrementing_ids(ProtocolVariant variant) [MemberData(nameof(KestrelOnly))] public async Task Sse_should_concatenate_multiline_data(ProtocolVariant variant) { - if (!Server.HasCustomEndpoints) Assert.Skip("Custom SSE endpoints not available on this backend."); + if (!Server.HasCustomEndpoints) + { + Assert.Skip("Custom SSE endpoints not available on this backend."); + } + await using var helper = CreateClient(variant); var materializer = ActorSystem.Materializer(); @@ -101,7 +105,11 @@ public async Task Sse_should_concatenate_multiline_data(ProtocolVariant variant) [MemberData(nameof(KestrelOnly))] public async Task Sse_should_skip_comment_lines(ProtocolVariant variant) { - if (!Server.HasCustomEndpoints) Assert.Skip("Custom SSE endpoints not available on this backend."); + if (!Server.HasCustomEndpoints) + { + Assert.Skip("Custom SSE endpoints not available on this backend."); + } + await using var helper = CreateClient(variant); var materializer = ActorSystem.Materializer(); @@ -119,7 +127,11 @@ public async Task Sse_should_skip_comment_lines(ProtocolVariant variant) [MemberData(nameof(KestrelOnly))] public async Task Sse_should_parse_id_and_retry_fields(ProtocolVariant variant) { - if (!Server.HasCustomEndpoints) Assert.Skip("Custom SSE endpoints not available on this backend."); + if (!Server.HasCustomEndpoints) + { + Assert.Skip("Custom SSE endpoints not available on this backend."); + } + await using var helper = CreateClient(variant); var materializer = ActorSystem.Materializer(); diff --git a/src/TurboHTTP.IntegrationTests.Client/H11/ChannelApiSpec.cs b/src/TurboHTTP.IntegrationTests.Client/H11/ChannelApiSpec.cs new file mode 100644 index 000000000..cdd3471da --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Client/H11/ChannelApiSpec.cs @@ -0,0 +1,76 @@ +using System.Net; +using TurboHTTP.IntegrationTests.Client.Shared; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.IntegrationTests.Client.H11; + +[Collection("H11")] +public sealed class ChannelApiSpec : IntegrationSpecBase +{ + public ChannelApiSpec(ServerContainerFixture server, ActorSystemFixture systemFixture) + : base(server, systemFixture) + { + } + + private static readonly ProtocolVariant H11 = new(TestHttpVersion.H11, tls: false); + + [Fact(Timeout = 15000)] + public async Task Channel_should_handle_get_roundtrip() + { + await using var helper = CreateClient(H11); + var client = helper.Client; + + await client.Requests.WriteAsync( + new HttpRequestMessage(HttpMethod.Get, "/get"), CancellationToken); + + Assert.True(await client.Responses.WaitToReadAsync(CancellationToken)); + Assert.True(client.Responses.TryRead(out var response)); + Assert.Equal(HttpStatusCode.OK, response!.StatusCode); + response.Dispose(); + } + + [Fact(Timeout = 15000)] + public async Task Channel_should_handle_post_with_small_body() + { + await using var helper = CreateClient(H11); + var client = helper.Client; + + var request = new HttpRequestMessage(HttpMethod.Post, "/post") + { + Content = new ByteArrayContent(new byte[1024]) + }; + await client.Requests.WriteAsync(request, CancellationToken); + + Assert.True(await client.Responses.WaitToReadAsync(CancellationToken)); + Assert.True(client.Responses.TryRead(out var response)); + Assert.Equal(HttpStatusCode.OK, response!.StatusCode); + response.Dispose(); + } + + [Fact(Timeout = 30000)] + public async Task Channel_should_handle_post_with_1mb_body() + { + await using var helper = CreateClient(H11); + var client = helper.Client; + + var payload = new byte[1 * 1024 * 1024]; + for (var i = 0; i < payload.Length; i++) + { + payload[i] = (byte)(i % 256); + } + + var request = new HttpRequestMessage(HttpMethod.Post, "/post") + { + Content = new ByteArrayContent(payload) + }; + await client.Requests.WriteAsync(request, CancellationToken); + + Assert.True(await client.Responses.WaitToReadAsync(CancellationToken)); + Assert.True(client.Responses.TryRead(out var response)); + Assert.Equal(HttpStatusCode.OK, response!.StatusCode); + + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Contains("url", body); + response.Dispose(); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Client/H11/PipeTransportSmokeSpec.cs b/src/TurboHTTP.IntegrationTests.Client/H11/PipeTransportSmokeSpec.cs new file mode 100644 index 000000000..7bd16fe4a --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Client/H11/PipeTransportSmokeSpec.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using TurboHTTP.IntegrationTests.Client.Shared; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.IntegrationTests.Client.H11; + +[Collection("H11")] +public sealed class PipeTransportSmokeSpec : IntegrationSpecBase +{ + public PipeTransportSmokeSpec(ServerContainerFixture server, ActorSystemFixture systemFixture) + : base(server, systemFixture) + { + } + + private static readonly ProtocolVariant H11Cleartext = new(TestHttpVersion.H11, tls: false); + + [Fact(Timeout = 30000)] + public async Task Get_should_return_200() + { + await using var helper = CreateClient(H11Cleartext, configureOptions: _ => { }); + + var response = await helper.Client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/get"), + CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Timeout = 30000)] + public async Task Get_should_return_json_body() + { + await using var helper = CreateClient(H11Cleartext, configureOptions: _ => { }); + + var response = await helper.Client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/get"), + CancellationToken); + + var body = await response.Content.ReadAsStringAsync(CancellationToken); + var json = JsonDocument.Parse(body); + + Assert.True(json.RootElement.TryGetProperty("url", out _)); + } + + [Fact(Timeout = 30000)] + public async Task Post_should_echo_request_body() + { + await using var helper = CreateClient(H11Cleartext, configureOptions: _ => { }); + + var payload = """{"key":"value"}"""; + var request = new HttpRequestMessage(HttpMethod.Post, "/post") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + var response = await helper.Client.SendAsync(request, CancellationToken); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + var json = JsonDocument.Parse(body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(payload, json.RootElement.GetProperty("data").GetString()); + } + + [Fact(Timeout = 30000)] + public async Task Status_endpoint_should_return_requested_status_code() + { + await using var helper = CreateClient(H11Cleartext, configureOptions: _ => { }); + + var response = await helper.Client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/status/418"), + CancellationToken); + + Assert.Equal((HttpStatusCode)418, response.StatusCode); + } + + [Fact(Timeout = 30000)] + public async Task Bytes_endpoint_should_return_correct_length() + { + await using var helper = CreateClient(H11Cleartext, configureOptions: _ => { }); + + var response = await helper.Client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "/bytes/1024"), + CancellationToken); + + var content = await response.Content.ReadAsByteArrayAsync(CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1024, content.Length); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Client/Shared/Collections.cs b/src/TurboHTTP.IntegrationTests.Client/Shared/Collections.cs index 6a75d1227..7a3f04e88 100644 --- a/src/TurboHTTP.IntegrationTests.Client/Shared/Collections.cs +++ b/src/TurboHTTP.IntegrationTests.Client/Shared/Collections.cs @@ -46,4 +46,7 @@ public sealed class SseIntegrationCollection; public sealed class StreamingIntegrationCollection; [CollectionDefinition("Timing")] -public sealed class TimingIntegrationCollection; \ No newline at end of file +public sealed class TimingIntegrationCollection; + +[CollectionDefinition("Cancellation")] +public sealed class CancellationIntegrationCollection; \ No newline at end of file diff --git a/src/TurboHTTP.IntegrationTests.Client/Shared/DockerTestBackend.cs b/src/TurboHTTP.IntegrationTests.Client/Shared/DockerTestBackend.cs index 3fac3d7b6..1764b0e99 100644 --- a/src/TurboHTTP.IntegrationTests.Client/Shared/DockerTestBackend.cs +++ b/src/TurboHTTP.IntegrationTests.Client/Shared/DockerTestBackend.cs @@ -59,10 +59,25 @@ public async Task StartAsync() public async ValueTask DisposeAsync() { - if (_nginxH3 is not null) await _nginxH3.DisposeAsync(); - if (_nginxH2 is not null) await _nginxH2.DisposeAsync(); - if (_httpbin is not null) await _httpbin.DisposeAsync(); - if (_network is not null) await _network.DisposeAsync(); + if (_nginxH3 is not null) + { + await _nginxH3.DisposeAsync(); + } + + if (_nginxH2 is not null) + { + await _nginxH2.DisposeAsync(); + } + + if (_httpbin is not null) + { + await _httpbin.DisposeAsync(); + } + + if (_network is not null) + { + await _network.DisposeAsync(); + } } private static async Task RemoveStaleResourcesAsync() diff --git a/src/TurboHTTP.IntegrationTests.Client/Shared/IntegrationSpecBase.cs b/src/TurboHTTP.IntegrationTests.Client/Shared/IntegrationSpecBase.cs index 2fb1628ec..4a2415cf3 100644 --- a/src/TurboHTTP.IntegrationTests.Client/Shared/IntegrationSpecBase.cs +++ b/src/TurboHTTP.IntegrationTests.Client/Shared/IntegrationSpecBase.cs @@ -72,7 +72,7 @@ private void SkipIfUnavailable(ProtocolVariant variant) Assert.Skip("QUIC is not available."); } - if (variant.Version == TestHttpVersion.H10 && variant.Tls && !Server.IsHttp10TlsSupported) + if (variant is { Version: TestHttpVersion.H10, Tls: true } && !Server.IsHttp10TlsSupported) { Assert.Skip("HTTP/1.0 over TLS is not supported by this backend."); } diff --git a/src/TurboHTTP.IntegrationTests.Client/Shared/KestrelTestBackend.cs b/src/TurboHTTP.IntegrationTests.Client/Shared/KestrelTestBackend.cs index 7ea0eff22..d603a365e 100644 --- a/src/TurboHTTP.IntegrationTests.Client/Shared/KestrelTestBackend.cs +++ b/src/TurboHTTP.IntegrationTests.Client/Shared/KestrelTestBackend.cs @@ -27,8 +27,6 @@ public async Task StartAsync() var cert = LoadCertificate(); var quicSupported = QuicListener.IsSupported; - var httpsPort = quicSupported ? FindAvailablePort() : 0; - var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); @@ -39,7 +37,7 @@ public async Task StartAsync() listenOptions.Protocols = HttpProtocols.Http1; }); - kestrel.Listen(IPAddress.Loopback, httpsPort, listenOptions => + kestrel.Listen(IPAddress.Loopback, 0, listenOptions => { listenOptions.Protocols = quicSupported ? HttpProtocols.Http1AndHttp2AndHttp3 @@ -131,14 +129,6 @@ await Console.Error.WriteLineAsync( } } - private static int FindAvailablePort() - { - using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } private static X509Certificate2 LoadCertificate() { diff --git a/src/TurboHTTP.IntegrationTests.Client/Shared/ServerContainerFixture.cs b/src/TurboHTTP.IntegrationTests.Client/Shared/ServerContainerFixture.cs index 7ba72e5dd..44e4bce4d 100644 --- a/src/TurboHTTP.IntegrationTests.Client/Shared/ServerContainerFixture.cs +++ b/src/TurboHTTP.IntegrationTests.Client/Shared/ServerContainerFixture.cs @@ -65,7 +65,11 @@ internal static async Task ProbeDockerAsync() UseShellExecute = false, CreateNoWindow = true }); - if (process is null) return false; + if (process is null) + { + return false; + } + await process.WaitForExitAsync(cts.Token); return process.ExitCode == 0; } diff --git a/src/TurboHTTP.IntegrationTests.Client/TestInitializer.cs b/src/TurboHTTP.IntegrationTests.Client/TestInitializer.cs new file mode 100644 index 000000000..d42827699 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Client/TestInitializer.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace TurboHTTP.IntegrationTests.Client; + +internal static class TestInitializer +{ + [ModuleInitializer] + internal static void Initialize() + { + ThreadPool.SetMinThreads(256, 256); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Client/xunit.runner.json b/src/TurboHTTP.IntegrationTests.Client/xunit.runner.json index 82207c373..73179ea81 100644 --- a/src/TurboHTTP.IntegrationTests.Client/xunit.runner.json +++ b/src/TurboHTTP.IntegrationTests.Client/xunit.runner.json @@ -2,5 +2,5 @@ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", "parallelizeTestCollections": true, "parallelizeAssembly": false, - "maxParallelThreads": 0 + "maxParallelThreads": 4 } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H10/ConnectionReuseSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H10/ConnectionReuseSpec.cs new file mode 100644 index 000000000..048cb17be --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H10/ConnectionReuseSpec.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H10; + +[Collection("H10")] +public sealed class ConnectionReuseSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version10; + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/connection-id", (HttpContext ctx) => + { + var remotePort = ctx.Connection.RemotePort; + return Results.Ok(new { remotePort }); + }); + } + + [Fact(Timeout = 15000)] + public async Task Http10_should_not_reuse_connections() + { + var remotePort1 = await GetRemotePort(); + var remotePort2 = await GetRemotePort(); + var remotePort3 = await GetRemotePort(); + + // H1.0 closes the connection after each request, so each request should come from a different ephemeral port + Assert.NotEqual(remotePort1, remotePort2); + Assert.NotEqual(remotePort2, remotePort3); + Assert.NotEqual(remotePort1, remotePort3); + } + + private async Task GetRemotePort() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/connection-id"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + using var doc = JsonDocument.Parse(body); + var port = doc.RootElement.GetProperty("remotePort").GetInt32(); + return port; + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs index f9126724c..14ef2e6e0 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs @@ -11,15 +11,19 @@ public sealed class LargePayloadSpec : End2EndSpecBase { protected override Version ProtocolVersion => HttpVersion.Version10; + // Under cross-module CPU contention on small CI runners even cheap requests can + // stall past the 10s default; stay below the 30s watchdogs instead. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(25); + protected override void ConfigureEndpoints(WebApplication app) { app.MapPost("/echo-bytes", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var data = stream.ToArray(); ctx.Response.ContentType = "application/octet-stream"; - await ctx.Response.Body.WriteAsync(data, CancellationToken); + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); }); app.MapGet("/generate", async (int size, HttpContext ctx) => @@ -31,7 +35,7 @@ protected override void ConfigureEndpoints(WebApplication app) while (remaining > 0) { var toWrite = Math.Min(1024, remaining); - await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), CancellationToken); + await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), ctx.RequestAborted); remaining -= toWrite; } }); @@ -39,10 +43,10 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/empty-echo", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var length = stream.Length; ctx.Response.ContentType = "text/plain"; - await ctx.Response.WriteAsync(length.ToString(), CancellationToken); + await ctx.Response.WriteAsync(length.ToString(), ctx.RequestAborted); }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H10/ResilienceSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H10/ResilienceSpec.cs index 80f4ef52b..a8ca12f7d 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H10/ResilienceSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H10/ResilienceSpec.cs @@ -16,9 +16,9 @@ protected override void ConfigureEndpoints(WebApplication app) { app.MapGet("/fast", () => Results.Text("ok")); - app.MapGet("/slow", async () => + app.MapGet("/slow", async (HttpContext ctx) => { - await Task.Delay(30000, CancellationToken); + await Task.Delay(30000, ctx.RequestAborted); return Results.Ok("done"); }); diff --git a/src/TurboHTTP.IntegrationTests.End2End/H10/RoundtripSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H10/RoundtripSpec.cs index 6d970b2ad..0f00c9e66 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H10/RoundtripSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H10/RoundtripSpec.cs @@ -18,7 +18,7 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/echo", async (HttpContext ctx) => { using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); + var body = await reader.ReadToEndAsync(ctx.RequestAborted); return Results.Ok(body); }); diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs new file mode 100644 index 000000000..e79bea691 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +/// +/// Verifies HTTPS requests tunnel through a forward proxy via CONNECT: an in-process +/// CONNECT proxy terminates the handshake, relays the TLS bytes to the real TurboServer, +/// and records the CONNECT request line and headers for assertions. +/// +[Collection("H11")] +public sealed class ConnectTunnelSpec : End2EndSpecBase +{ + private ConnectProxy? _proxy; + + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override bool UseTls => true; + + protected override void ConfigureServer( + TurboServerOptions options, ushort port, System.Security.Cryptography.X509Certificates.X509Certificate2? cert) + { + // The base binds HTTP/1.1 without TLS; a CONNECT tunnel only makes sense for HTTPS. + options.ListenLocalhost(port, listen => + { + listen.UseHttps(cert!); + listen.Protocols = HttpProtocols.Http1; + }); + } + + protected override void ConfigureClientOptions(TurboClientOptions options) + { + _proxy = new ConnectProxy(); + _proxy.Start(); + + options.UseProxy = true; + options.Proxy = new FixedProxy(new Uri($"http://127.0.0.1:{_proxy.Port}")); + options.DefaultProxyCredentials = new NetworkCredential("tunnel-user", "tunnel-pass"); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/tunneled", () => Results.Text("through-connect-tunnel")); + } + + public override async ValueTask DisposeAsync() + { + _proxy?.Dispose(); + await base.DisposeAsync(); + } + + [Fact(Timeout = 15000)] + public async Task Https_request_should_tunnel_through_connect_proxy() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/tunneled"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Equal("through-connect-tunnel", body); + + Assert.True(_proxy!.ConnectCount >= 1, "Request did not tunnel through the CONNECT proxy"); + var server = new Uri(BaseUri); + Assert.Contains($"CONNECT {server.Host}:{server.Port} HTTP/1.1", _proxy.LastConnectRequest); + } + + [Fact(Timeout = 15000)] + public async Task Connect_request_should_carry_proxy_authorization() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/tunneled"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes("tunnel-user:tunnel-pass")); + Assert.Contains($"Proxy-Authorization: Basic {expected}", _proxy!.LastConnectRequest); + } + + /// An that always routes to a fixed proxy and never bypasses. + private sealed class FixedProxy(Uri proxy) : IWebProxy + { + public ICredentials? Credentials { get; set; } + public Uri GetProxy(Uri destination) => proxy; + public bool IsBypassed(Uri host) => false; + } + + private sealed class ConnectProxy : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(); + private int _connectCount; + private volatile string _lastConnectRequest = string.Empty; + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public int ConnectCount => Volatile.Read(ref _connectCount); + public string LastConnectRequest => _lastConnectRequest; + + public void Start() + { + _listener.Start(); + _ = AcceptLoop(); + } + + private async Task AcceptLoop() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token); + _ = TunnelAsync(client); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + + private async Task TunnelAsync(TcpClient downstream) + { + try + { + using (downstream) + { + var ds = downstream.GetStream(); + + var headerBytes = await ReadConnectRequestAsync(ds); + var headerText = Encoding.ASCII.GetString(headerBytes); + _lastConnectRequest = headerText; + + var requestLine = headerText[..headerText.IndexOf('\r')].Split(' '); + if (requestLine.Length < 2 || requestLine[0] != "CONNECT") + { + await WriteAsciiAsync(ds, "HTTP/1.1 405 Method Not Allowed\r\n\r\n"); + return; + } + + Interlocked.Increment(ref _connectCount); + + var target = requestLine[1].Split(':'); + using var upstream = new TcpClient(); + await upstream.ConnectAsync(target[0], int.Parse(target[1]), _cts.Token); + + await WriteAsciiAsync(ds, "HTTP/1.1 200 Connection Established\r\n\r\n"); + + await using var us = upstream.GetStream(); + var toUpstream = ds.CopyToAsync(us, _cts.Token); + var toDownstream = us.CopyToAsync(ds, _cts.Token); + await Task.WhenAny(toUpstream, toDownstream); + } + } + catch + { + // Best-effort tunnel; the connection is torn down with the test. + } + } + + private async Task ReadConnectRequestAsync(NetworkStream stream) + { + var buffer = new byte[8 * 1024]; + var total = 0; + while (total < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), _cts.Token); + if (read == 0) + { + break; + } + + total += read; + if (buffer.AsSpan(0, total).IndexOf("\r\n\r\n"u8) >= 0) + { + break; + } + } + + return buffer[..total]; + } + + private async Task WriteAsciiAsync(NetworkStream stream, string text) + { + await stream.WriteAsync(Encoding.ASCII.GetBytes(text), _cts.Token); + await stream.FlushAsync(_cts.Token); + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + } + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectionPoolingSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectionPoolingSpec.cs new file mode 100644 index 000000000..5b33c68de --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectionPoolingSpec.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +[Collection("H11")] +public sealed class ConnectionPoolingSpec : End2EndSpecBase +{ + private const int MaxConnections = 2; + + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override void ConfigureClientOptions(TurboClientOptions options) + { + // Cap the pool at MaxConnections and disable pipelining so each in-flight request + // needs its own connection slot — making the cap observable under concurrency. + options.Http1.MaxConnectionsPerServer = MaxConnections; + options.Http1.MaxPipelineDepth = 1; + options.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + // Hold each request briefly so concurrent requests overlap and contend for connections. + app.MapGet("/slow", async (HttpContext ctx) => + { + await Task.Delay(250); + return Results.Ok(new { remotePort = ctx.Connection.RemotePort }); + }); + } + + [Fact(Timeout = 20000)] + public async Task Http11_should_not_exceed_MaxConnectionsPerServer_under_concurrency() + { + var tasks = Enumerable.Range(0, 8) + .Select(_ => GetRemotePort()) + .ToArray(); + + var ports = await Task.WhenAll(tasks); + var distinct = ports.Distinct().Count(); + + // The pool must never open more than the configured cap... + Assert.True(distinct <= MaxConnections, $"Opened {distinct} connections, cap is {MaxConnections}"); + // ...and under this much concurrency it should actually use the full cap (proves real pooling, not a single shared connection). + Assert.Equal(MaxConnections, distinct); + } + + private async Task GetRemotePort() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/slow"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + using var doc = JsonDocument.Parse(body); + return doc.RootElement.GetProperty("remotePort").GetInt32(); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectionReuseSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectionReuseSpec.cs new file mode 100644 index 000000000..d40f67c12 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectionReuseSpec.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +[Collection("H11")] +public sealed class ConnectionReuseSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override void ConfigureClientOptions(TurboClientOptions options) + { + // Ensure connection pooling is explicitly enabled with a reasonable idle timeout + options.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30); + options.Http1.MaxConnectionsPerServer = 1; // Force reuse by allowing only 1 connection + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/connection-id", (HttpContext ctx) => + { + var remotePort = ctx.Connection.RemotePort; + return Results.Ok(new { remotePort }); + }); + } + + [Fact(Timeout = 15000)] + public async Task Http11_should_reuse_connections() + { + var remotePort1 = await GetRemotePort(); + var remotePort2 = await GetRemotePort(); + var remotePort3 = await GetRemotePort(); + + // H1.1 with keep-alive (default) reuses the TCP connection, so all requests should come from the same ephemeral port. + // All requests through the same ITurboHttpClient instance should originate from a single, pooled connection. + Assert.Equal(remotePort1, remotePort2); + Assert.Equal(remotePort2, remotePort3); + } + + private async Task GetRemotePort() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/connection-id"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + using var doc = JsonDocument.Parse(body); + var port = doc.RootElement.GetProperty("remotePort").GetInt32(); + return port; + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/CookieSameSiteSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/CookieSameSiteSpec.cs new file mode 100644 index 000000000..9e80ec7cb --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/CookieSameSiteSpec.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Text.Json; +using Akka.Actor; +using Akka.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Servus.Akka.Transport; +using TurboHTTP.Client; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +[Collection("H11")] +public sealed class CookieSameSiteSpec : IAsyncLifetime +{ + private WebApplication? _app; + private ITurboHttpClient? _client; + private Microsoft.Extensions.DependencyInjection.ServiceProvider? _clientProvider; + + private string BaseUri { get; set; } = string.Empty; + + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + private ITurboHttpClient Client => _client!; + + public async ValueTask InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + + // Bind port 0 and read the real port back after start — probing for a free + // port and rebinding it races with parallel tests (and parallel test modules). + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions + { + Host = "127.0.0.1", + Port = 0 + }); + }); + + _app = builder.Build(); + + _app.MapGet("/cookie/set-strict", (HttpContext ctx) => + { + ctx.Response.Headers.SetCookie = "stricttoken=secret123; Path=/; SameSite=Strict"; + return Results.Json(new { message = "Cookie set" }); + }); + + _app.MapGet("/cookie/echo", (HttpContext ctx) => + { + var cookieHeader = ctx.Request.Headers.Cookie.ToString(); + var cookies = new Dictionary(); + + if (!string.IsNullOrEmpty(cookieHeader)) + { + foreach (var pair in cookieHeader.Split(';', StringSplitOptions.TrimEntries)) + { + var eq = pair.IndexOf('='); + if (eq > 0) + { + cookies[pair[..eq].Trim()] = pair[(eq + 1)..].Trim(); + } + } + } + + return Results.Json(cookies); + }); + + await _app.StartAsync(CancellationToken); + + var address = _app.Services.GetRequiredService() + .Features.Get()! + .Addresses.First(); + BaseUri = $"http://127.0.0.1:{new Uri(address).Port}"; + + var services = new ServiceCollection(); + + var diSetup = DependencyResolverSetup.Create(services.BuildServiceProvider()); + var bootstrap = BootstrapSetup.Create(); + var system = ActorSystem.Create($"e2e-cookie-samesite-{Guid.NewGuid():N}", bootstrap.And(diSetup)); + services.AddSingleton(system); + + var clientBuilder = services.AddTurboHttpClient(string.Empty, options => + { + options.BaseAddress = new Uri(BaseUri); + options.DangerousAcceptAnyServerCertificate = false; + }); + clientBuilder.WithCookies(); + + _clientProvider = services.BuildServiceProvider(); + + var factory = _clientProvider.GetRequiredService(); + _client = factory.CreateClient(string.Empty); + _client.DefaultRequestVersion = HttpVersion.Version11; + _client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + _client.Timeout = TimeSpan.FromSeconds(10); + } + + public async ValueTask DisposeAsync() + { + _client?.Dispose(); + + if (_app is not null) + { + await _app.StopAsync(CancellationToken); + await _app.DisposeAsync(); + } + + if (_clientProvider is not null) + { + var system = _clientProvider.GetService(); + if (system is not null) + { + await system.Terminate().WaitAsync(TimeSpan.FromSeconds(10), CancellationToken); + await system.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken); + } + + await _clientProvider.DisposeAsync(); + } + } + + [Fact(Timeout = 15000)] + public async Task SameSiteStrict_should_be_sent_on_first_party_request() + { + var setCookieRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/cookie/set-strict"); + var setCookieResponse = await Client.SendAsync(setCookieRequest, CancellationToken); + Assert.Equal(HttpStatusCode.OK, setCookieResponse.StatusCode); + await Task.Delay(150, CancellationToken); + var echoRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/cookie/echo"); + var echoResponse = await Client.SendAsync(echoRequest, CancellationToken); + Assert.Equal(HttpStatusCode.OK, echoResponse.StatusCode); + + var json = await echoResponse.Content.ReadAsStringAsync(CancellationToken); + var cookies = JsonSerializer.Deserialize>(json); + + Assert.NotNull(cookies); + Assert.True(cookies.ContainsKey("stricttoken"), "SameSite=Strict cookie should be sent on first-party request"); + Assert.Equal("secret123", cookies["stricttoken"]); + } + + [Fact(Timeout = 15000)] + public async Task SameSiteStrict_should_not_be_sent_on_cross_site_request() + { + var setCookieRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/cookie/set-strict"); + var setCookieResponse = await Client.SendAsync(setCookieRequest, CancellationToken); + Assert.Equal(HttpStatusCode.OK, setCookieResponse.StatusCode); + + var crossSiteUri = new Uri("http://other.example.test:9999"); + var echoRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/cookie/echo") + .WithFirstPartyContext(crossSiteUri); + + var echoResponse = await Client.SendAsync(echoRequest, CancellationToken); + Assert.Equal(HttpStatusCode.OK, echoResponse.StatusCode); + + var json = await echoResponse.Content.ReadAsStringAsync(CancellationToken); + var cookies = JsonSerializer.Deserialize>(json); + + Assert.NotNull(cookies); + Assert.False(cookies.ContainsKey("stricttoken"), + "SameSite=Strict cookie should NOT be sent on cross-site request"); + } +} \ No newline at end of file diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/HandlerMiddlewareSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/HandlerMiddlewareSpec.cs new file mode 100644 index 000000000..11e56a936 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/HandlerMiddlewareSpec.cs @@ -0,0 +1,148 @@ +using System.Net; +using Akka.Actor; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +[Collection("H11")] +public sealed class HandlerMiddlewareSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/ping", () => Results.Ok("pong")); + + app.MapGet("/echo-headers", (HttpContext ctx) => + { + var injected = ctx.Request.Headers["X-Handler-Injected"].ToString(); + var response = new Dictionary + { + ["x-handler-injected"] = injected + }; + return Results.Ok(response); + }); + } + + private sealed class HeaderInjectionHandler : TurboHandler + { + public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) + { + request.Headers.TryAddWithoutValidation("X-Handler-Injected", "success"); + return request; + } + } + + private sealed class FailingHandler : TurboHandler + { + public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) + { + throw new InvalidOperationException("Handler intentionally throwing"); + } + } + + private sealed class ConditionalFailingHandler : TurboHandler + { + public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) + { + if (request.Headers.Contains("X-Fail")) + { + throw new InvalidOperationException("Conditional handler failure"); + } + return request; + } + } + + [Fact(Timeout = 10000)] + public async Task Handler_should_inject_request_headers_that_reach_server() + { + // Create a separate client with header-injecting handler + var system = await GetActorSystemAsync(); + var client = CreateClientWithHandler(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/echo-headers"); + var response = await client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Contains("success", body); + } + + [Fact(Timeout = 10000)] + public async Task Handler_should_fail_per_request_when_throwing() + { + var client = CreateClientWithHandler(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + var ex = await Assert.ThrowsAsync(() => client.SendAsync(request, CancellationToken)); + Assert.Contains("Handler intentionally throwing", ex.Message); + } + + [Fact(Timeout = 10000)] + public async Task Handler_should_fail_only_faulted_request_while_others_succeed() + { + var client = CreateClientWithHandler(); + + // Send a failing request + var failingRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + failingRequest.Headers.Add("X-Fail", "yes"); + + // Send a good request + var goodRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + + // Execute them sequentially to test per-request isolation + var failTask = client.SendAsync(failingRequest, CancellationToken); + + // This should throw + _ = await Assert.ThrowsAsync(() => failTask); + + // Now send the good request — it should succeed despite the handler + var goodResponse = await client.SendAsync(goodRequest, CancellationToken); + Assert.Equal(HttpStatusCode.OK, goodResponse.StatusCode); + } + + private ITurboHttpClient CreateClientWithHandler() where THandler : TurboHandler + { + var services = new ServiceCollection(); + services.AddSingleton(GetActorSystemAsync().Result); + + var clientOptions = new TurboClientOptions + { + BaseAddress = new Uri(BaseUri), + DangerousAcceptAnyServerCertificate = false + }; + + services.AddTurboHttpClient() + .AddHandler(); + + services.Replace(ServiceDescriptor.Singleton>( + new FixedOptionsFactory(clientOptions))); + + var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var client = factory.CreateClient(string.Empty); + client.DefaultRequestVersion = ProtocolVersion; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + client.Timeout = TimeSpan.FromSeconds(10); + + return client; + } + + private async Task GetActorSystemAsync() + { + // Create a minimal ActorSystem for the test client + var setup = BootstrapSetup.Create(); + return await Task.FromResult(ActorSystem.Create($"test-handler-{Guid.NewGuid():N}", setup)); + } + + private sealed class FixedOptionsFactory(TurboClientOptions options) : IOptionsFactory + { + public TurboClientOptions Create(string name) => options; + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/LargePayloadSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/LargePayloadSpec.cs index f1a2f41c5..7a2c7455c 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H11/LargePayloadSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/LargePayloadSpec.cs @@ -11,15 +11,19 @@ public sealed class LargePayloadSpec : End2EndSpecBase { protected override Version ProtocolVersion => HttpVersion.Version11; + // Under cross-module CPU contention on small CI runners even cheap requests can + // stall past the 10s default; stay below the 30s watchdogs instead. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(25); + protected override void ConfigureEndpoints(WebApplication app) { app.MapPost("/echo-bytes", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var data = stream.ToArray(); ctx.Response.ContentType = "application/octet-stream"; - await ctx.Response.Body.WriteAsync(data, CancellationToken); + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); }); app.MapGet("/generate", async (int size, HttpContext ctx) => @@ -31,7 +35,7 @@ protected override void ConfigureEndpoints(WebApplication app) while (remaining > 0) { var toWrite = Math.Min(1024, remaining); - await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), CancellationToken); + await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), ctx.RequestAborted); remaining -= toWrite; } }); @@ -39,10 +43,10 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/empty-echo", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var length = stream.Length; ctx.Response.ContentType = "text/plain"; - await ctx.Response.WriteAsync(length.ToString(), CancellationToken); + await ctx.Response.WriteAsync(length.ToString(), ctx.RequestAborted); }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/PerRequestTimeoutSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/PerRequestTimeoutSpec.cs new file mode 100644 index 000000000..cba6da91f --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/PerRequestTimeoutSpec.cs @@ -0,0 +1,64 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +[Collection("H11")] +public sealed class PerRequestTimeoutSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override void ConfigureEndpoints(WebApplication app) + { + // Responds only after 3s — far below the 10s global client timeout the base sets, + // so without a per-request timeout this request SUCCEEDS. + app.MapGet("/slow", async () => + { + await Task.Delay(TimeSpan.FromSeconds(3)); + return Results.Ok("slow"); + }); + } + + [Fact(Timeout = 15000)] + public async Task PerRequestTimeout_should_cancel_slow_request_before_global_timeout() + { + // Global timeout is 10s (set by the base). The 3s endpoint would otherwise succeed. + // A 500ms per-request timeout must cancel it first — fails if WithTimeout is ignored. + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/slow") + .WithTimeout(TimeSpan.FromMilliseconds(500)); + + await Assert.ThrowsAsync( + () => Client.SendAsync(request, CancellationToken)); + } + + [Fact(Timeout = 15000)] + public async Task PerRequestTimeout_should_not_affect_request_without_it() + { + // Same 3s endpoint, no per-request timeout → the 10s global timeout lets it complete. + // Proves the cancellation above is caused by the per-request value, not the endpoint itself. + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/slow"); + + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Timeout = 15000)] + public async Task PerRequestTimeout_should_not_leak_to_subsequent_request() + { + // A short per-request timeout must not stick to the client: the next request + // without one falls back to the 10s global timeout and completes. + var timed = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/slow") + .WithTimeout(TimeSpan.FromMilliseconds(500)); + await Assert.ThrowsAsync( + () => Client.SendAsync(timed, CancellationToken)); + + var plain = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/slow"); + var response = await Client.SendAsync(plain, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/ProxySpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/ProxySpec.cs new file mode 100644 index 000000000..bce1a6c95 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/ProxySpec.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +/// +/// Verifies the client actually routes through a configured proxy: a transparent in-process +/// relay proxy forwards to the real TurboServer and counts how many connections it received. +/// +[Collection("H11")] +public sealed class ProxySpec : End2EndSpecBase +{ + private RelayProxy? _proxy; + + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override void ConfigureClientOptions(TurboClientOptions options) + { + var server = new Uri(BaseUri); + _proxy = new RelayProxy(server.Host, server.Port); + _proxy.Start(); + + options.UseProxy = true; + options.Proxy = new FixedProxy(new Uri($"http://127.0.0.1:{_proxy.Port}")); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/via-proxy", () => Results.Text("through-proxy")); + } + + public override async ValueTask DisposeAsync() + { + _proxy?.Dispose(); + await base.DisposeAsync(); + } + + [Fact(Timeout = 15000)] + public async Task Client_should_route_request_through_configured_proxy() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/via-proxy"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Contains("through-proxy", body); + Assert.True(_proxy!.ConnectionCount >= 1, "Request did not pass through the configured proxy"); + } + + /// An that always routes to a fixed proxy and never bypasses. + private sealed class FixedProxy(Uri proxy) : IWebProxy + { + public ICredentials? Credentials { get; set; } + public Uri GetProxy(Uri destination) => proxy; + public bool IsBypassed(Uri host) => false; + } + + private sealed class RelayProxy(string upstreamHost, int upstreamPort) : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(); + private int _connectionCount; + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public int ConnectionCount => Volatile.Read(ref _connectionCount); + + public void Start() + { + _listener.Start(); + _ = AcceptLoop(); + } + + private async Task AcceptLoop() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token); + Interlocked.Increment(ref _connectionCount); + _ = RelayAsync(client); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + + private async Task RelayAsync(TcpClient downstream) + { + try + { + using (downstream) + using (var upstream = new TcpClient()) + { + await upstream.ConnectAsync(upstreamHost, upstreamPort, _cts.Token); + await using var ds = downstream.GetStream(); + await using var us = upstream.GetStream(); + var toUpstream = ds.CopyToAsync(us, _cts.Token); + var toDownstream = us.CopyToAsync(ds, _cts.Token); + await Task.WhenAny(toUpstream, toDownstream); + } + } + catch + { + // Best-effort relay; the connection is torn down with the test. + } + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + } + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/ResilienceSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/ResilienceSpec.cs index 6c8b749c0..21d78644d 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H11/ResilienceSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/ResilienceSpec.cs @@ -16,9 +16,9 @@ protected override void ConfigureEndpoints(WebApplication app) { app.MapGet("/fast", () => Results.Text("ok")); - app.MapGet("/slow", async () => + app.MapGet("/slow", async (HttpContext ctx) => { - await Task.Delay(30000, CancellationToken); + await Task.Delay(30000, ctx.RequestAborted); return Results.Ok("done"); }); diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/RoundtripSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/RoundtripSpec.cs index 3c92f994f..6c8aea371 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H11/RoundtripSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/RoundtripSpec.cs @@ -18,14 +18,14 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/echo", async (HttpContext ctx) => { using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); + var body = await reader.ReadToEndAsync(ctx.RequestAborted); return Results.Ok(body); }); app.MapPut("/put-echo", async (HttpContext ctx) => { using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); + var body = await reader.ReadToEndAsync(ctx.RequestAborted); return Results.Ok(body); }); diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/StreamingSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/StreamingSpec.cs index 152a94e45..17a3917be 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H11/StreamingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/StreamingSpec.cs @@ -16,8 +16,8 @@ protected override void ConfigureEndpoints(WebApplication app) { for (var i = 0; i < 5; i++) { - await ctx.Response.WriteAsync($"chunk-{i}\n", CancellationToken); - await ctx.Response.Body.FlushAsync(CancellationToken); + await ctx.Response.WriteAsync($"chunk-{i}\n", ctx.RequestAborted); + await ctx.Response.Body.FlushAsync(ctx.RequestAborted); } }); @@ -26,8 +26,8 @@ protected override void ConfigureEndpoints(WebApplication app) ctx.Response.ContentType = "text/event-stream"; for (var i = 0; i < 3; i++) { - await ctx.Response.WriteAsync($"data: event-{i}\n\n", CancellationToken); - await ctx.Response.Body.FlushAsync(CancellationToken); + await ctx.Response.WriteAsync($"data: event-{i}\n\n", ctx.RequestAborted); + await ctx.Response.Body.FlushAsync(ctx.RequestAborted); } }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/WirePipeliningSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/WirePipeliningSpec.cs new file mode 100644 index 000000000..3bd4b11f8 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/WirePipeliningSpec.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +[Collection("H11")] +public sealed class WirePipeliningSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/p/{id:int}", (int id) => Results.Text($"RESP-{id}")); + } + + [Fact(Timeout = 15000)] + public async Task Http11_should_answer_pipelined_requests_in_order_on_one_connection() + { + var uri = new Uri(BaseUri); + var host = uri.Authority; + + using var tcp = new TcpClient { NoDelay = true }; + await tcp.ConnectAsync(uri.Host, uri.Port, CancellationToken); + await using var stream = tcp.GetStream(); + + var pipelined = + $"GET /p/1 HTTP/1.1\r\nHost: {host}\r\n\r\n" + + $"GET /p/2 HTTP/1.1\r\nHost: {host}\r\n\r\n" + + $"GET /p/3 HTTP/1.1\r\nHost: {host}\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(pipelined), CancellationToken); + + var raw = await ReadUntilThreeResponsesAsync(stream, tcp.Client); + + Assert.True(3 == CountOccurrences(raw, "HTTP/1.1 200"), + $"Expected 3 responses. Raw bytes ({raw.Length}):\n{raw}"); + + var i1 = raw.IndexOf("RESP-1", StringComparison.Ordinal); + var i2 = raw.IndexOf("RESP-2", StringComparison.Ordinal); + var i3 = raw.IndexOf("RESP-3", StringComparison.Ordinal); + Assert.True(i1 >= 0 && i2 > i1 && i3 > i2, + $"Pipelined responses out of order or missing (i1={i1}, i2={i2}, i3={i3})"); + } + + private async Task ReadUntilThreeResponsesAsync(NetworkStream stream, Socket socket) + { + socket.ReceiveTimeout = 10000; + var sb = new StringBuilder(); + var buffer = new byte[4096]; + + try + { + while (CountOccurrences(sb.ToString(), "HTTP/1.1 200") < 3) + { + var read = await Task.Run(() => stream.Read(buffer, 0, buffer.Length)); + if (read == 0) + { + break; + } + + sb.Append(Encoding.ASCII.GetString(buffer, 0, read)); + } + } + catch (IOException) { _ = sb; } + catch (SocketException) { _ = sb; } + + return sb.ToString(); + } + + private static int CountOccurrences(string haystack, string needle) + { + var count = 0; + var index = 0; + while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/AdaptiveWindowScalingSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/AdaptiveWindowScalingSpec.cs new file mode 100644 index 000000000..896f22234 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/AdaptiveWindowScalingSpec.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.End2End.H2; + +[Collection("H2")] +public sealed class AdaptiveWindowScalingSpec : End2EndSpecBase +{ + private bool _scalingEnabled = true; + + protected override Version ProtocolVersion => HttpVersion.Version20; + + // Bulk transfers through scaled windows can exceed the 10s default under + // CI contention; stay well below the 60s watchdogs instead. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(45); + + protected override void ConfigureClientOptions(TurboClientOptions options) + { + options.Http2.EnableAdaptiveWindowScaling = _scalingEnabled; + options.Http2.MaxConnectionsPerServer = 1; + } + + protected override void ConfigureServer(TurboServerOptions options, ushort port, X509Certificate2? cert) + { + base.ConfigureServer(options, port, cert); + options.Limits.MinResponseDataRate = 0; + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/generate", async ctx => + { + var size = int.Parse(ctx.Request.Query["size"]!); + ctx.Response.ContentType = "application/octet-stream"; + var buffer = new byte[64 * 1024]; + Array.Fill(buffer, (byte)0xCD); + var remaining = size; + while (remaining > 0) + { + var toWrite = Math.Min(buffer.Length, remaining); + await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), ctx.RequestAborted); + remaining -= toWrite; + } + }); + } + + [Fact(Timeout = 60000)] + public async Task AdaptiveScaling_should_handle_multiple_concurrent_large_responses() + { + const int concurrentRequests = 5; + const int responseSize = 2 * 1024 * 1024; + + var tasks = new Task<(bool success, int length)>[concurrentRequests]; + + for (var i = 0; i < concurrentRequests; i++) + { + tasks[i] = Task.Run(async () => + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/generate?size={responseSize}"); + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (false, 0); + } + + var body = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + if (body.Length != responseSize) + { + return (false, body.Length); + } + + if (!body.All(b => b == 0xCD)) + { + return (false, body.Length); + } + + return (true, body.Length); + }); + } + + var results = await Task.WhenAll(tasks); + + Assert.True(results.All(r => r.success), + $"Failures: {string.Join(", ", results.Where(r => !r.success).Select(r => $"len={r.length}"))}"); + } + + [Fact(Timeout = 60000)] + public async Task AdaptiveScaling_should_transfer_large_body_without_corruption() + { + const int responseSize = 4 * 1024 * 1024; + + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/generate?size={responseSize}"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsByteArrayAsync(CancellationToken); + + Assert.Equal(responseSize, body.Length); + Assert.True(body.All(b => b == 0xCD)); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/ConcurrentLargePostSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/ConcurrentLargePostSpec.cs new file mode 100644 index 000000000..458a45fc2 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/ConcurrentLargePostSpec.cs @@ -0,0 +1,239 @@ +using System.Net; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H2; + +[Collection("H2")] +public sealed class ConcurrentLargePostSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version20; + + // Many concurrent 1MB transfers on one connection can exceed the 10s default + // under CI contention; stay well below the 60-90s watchdogs instead. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(45); + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-bytes", async ctx => + { + using var stream = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); + var data = stream.ToArray(); + ctx.Response.ContentType = "application/octet-stream"; + // Diagnostic: lets a failing assert distinguish request-side truncation + // (server already received short data) from response-side truncation. + ctx.Response.Headers["X-Received-Length"] = data.Length.ToString(); + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); + }); + } + + [Fact(Timeout = 60000)] + public async Task ConcurrentLargePost_should_handle_concurrent_512KB_payloads_without_corruption() + { + const int concurrentRequests = 20; + const int payloadSize = 512 * 1024; + var payloads = new byte[concurrentRequests][]; + + // Generate unique random payloads + for (var i = 0; i < concurrentRequests; i++) + { + payloads[i] = new byte[payloadSize]; + RandomNumberGenerator.Fill(payloads[i]); + } + + var tasks = new Task<(int index, bool success, string error)>[concurrentRequests]; + + // Fire all requests concurrently on the same client/connection + for (var i = 0; i < concurrentRequests; i++) + { + var index = i; + tasks[i] = Task.Run(async () => + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payloads[index]) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (index, false, $"Status: {response.StatusCode}"); + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + if (responseBytes.Length != payloads[index].Length) + { + var receivedByServer = response.Headers.TryGetValues("X-Received-Length", out var v) + ? string.Join(",", v) + : "?"; + return (index, false, + $"Length mismatch: expected {payloads[index].Length}, got {responseBytes.Length} (server received {receivedByServer})"); + } + + if (!payloads[index].SequenceEqual(responseBytes)) + { + return (index, false, "Payload mismatch: response body does not match request"); + } + + return (index, true, ""); + } + catch (Exception ex) + { + return (index, false, ex.Message); + } + }); + } + + var results = await Task.WhenAll(tasks); + + var failedResults = results.Where(r => !r.success).ToArray(); + Assert.True(failedResults.Length == 0, + string.Join("; ", failedResults.Select(r => $"[{r.index}] {r.error}"))); + } + + [Fact(Timeout = 60000)] + public async Task ConcurrentLargePost_should_maintain_stream_isolation_under_flow_control() + { + const int concurrentRequests = 10; + const int payloadSize = 1024 * 1024; // 1 MB each + var payloads = new byte[concurrentRequests][]; + var checksums = new long[concurrentRequests]; + + // Generate unique payloads and compute checksums + for (var i = 0; i < concurrentRequests; i++) + { + payloads[i] = new byte[payloadSize]; + RandomNumberGenerator.Fill(payloads[i]); + + // Simple checksum + long sum = 0; + foreach (var b in payloads[i]) + { + sum += b; + } + checksums[i] = sum; + } + + var results = new (int index, long checksum, bool valid)[concurrentRequests]; + + var tasks = new Task[concurrentRequests]; + + // Fire all requests concurrently + for (var i = 0; i < concurrentRequests; i++) + { + var index = i; +#pragma warning disable xUnit1051 + tasks[i] = Task.Run(async () => + { +#pragma warning restore xUnit1051 + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payloads[index]) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + // Verify exact match + var receivedByServer = response.Headers.TryGetValues("X-Received-Length", out var v) + ? string.Join(",", v) + : "?"; + Assert.True(payloads[index].Length == responseBytes.Length, + $"Stream {index}: expected {payloads[index].Length} bytes, got {responseBytes.Length} (server received {receivedByServer})"); + Assert.True(payloads[index].SequenceEqual(responseBytes), + $"Stream {index}: response body mismatch"); + + // Compute checksum of response + long sum = 0; + foreach (var b in responseBytes) + { + sum += b; + } + + results[index] = (index, sum, sum == checksums[index]); + } + catch (Exception) + { + results[index] = (index, 0, false); + throw; + } + }); + } + + await Task.WhenAll(tasks); + + // Verify all checksums match + var invalidResults = results.Where(r => !r.valid).ToArray(); + Assert.Empty(invalidResults); + } + + [Fact(Timeout = 90000)] + public async Task ConcurrentLargePost_should_handle_interleaved_sends_and_receives() + { + const int concurrentRequests = 10; + const int payloadSize = 512 * 1024; + var payloads = new byte[concurrentRequests][]; + + for (var i = 0; i < concurrentRequests; i++) + { + payloads[i] = new byte[payloadSize]; + RandomNumberGenerator.Fill(payloads[i]); + } + + var semaphore = new SemaphoreSlim(5); + var tasks = new Task[concurrentRequests]; + + for (var i = 0; i < concurrentRequests; i++) + { + var index = i; + tasks[i] = Task.Run(async () => + { + await semaphore.WaitAsync(TestContext.Current.CancellationToken); + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payloads[index]) + }; + + var response = await Client.SendAsync(request, cts.Token); + + if (response.StatusCode != HttpStatusCode.OK) + { + return false; + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(cts.Token); + return payloads[index].SequenceEqual(responseBytes); + } + catch (OperationCanceledException) + { + return false; + } + finally + { + semaphore.Release(); + } + }); + } + + var results = await Task.WhenAll(tasks); + var successCount = results.Count(r => r); + + Assert.True(successCount >= concurrentRequests - 2, + $"Expected at least {concurrentRequests - 2} successes, got {successCount}/{concurrentRequests}"); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/ConnectionWindowStarvationSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/ConnectionWindowStarvationSpec.cs new file mode 100644 index 000000000..caad0ccc5 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/ConnectionWindowStarvationSpec.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.End2End.H2; + +[Collection("H2")] +public sealed class ConnectionWindowStarvationSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version20; + + // Bulk transfers through a deliberately small connection window can exceed the + // 10s default under CI contention; stay well below the 60s watchdogs instead. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(45); + + protected override void ConfigureClientOptions(TurboClientOptions options) + { + options.Http2.MaxConnectionsPerServer = 1; + } + + protected override void ConfigureServer(TurboServerOptions options, ushort port, X509Certificate2? cert) + { + base.ConfigureServer(options, port, cert); + options.Http2.InitialConnectionWindowSize = 512 * 1024; + options.Http2.InitialStreamWindowSize = 128 * 1024; + options.Limits.MinRequestBodyDataRate = 0; + options.Limits.MinResponseDataRate = 0; + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-bytes", async ctx => + { + using var stream = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); + var data = stream.ToArray(); + ctx.Response.ContentType = "application/octet-stream"; + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); + }); + } + + [Fact(Timeout = 60000)] + public async Task ConnectionWindowStarvation_should_complete_all_streams_with_small_connection_window() + { + const int concurrentRequests = 10; + const int payloadSize = 128 * 1024; + var payloads = new byte[concurrentRequests][]; + + for (var i = 0; i < concurrentRequests; i++) + { + payloads[i] = new byte[payloadSize]; + RandomNumberGenerator.Fill(payloads[i]); + } + + var tasks = new Task<(int index, bool success, string error)>[concurrentRequests]; + + for (var i = 0; i < concurrentRequests; i++) + { + var index = i; + tasks[i] = Task.Run(async () => + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payloads[index]) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (index, false, $"Status: {response.StatusCode}"); + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + return payloads[index].SequenceEqual(responseBytes) + ? (index, true, "") + : (index, false, $"Payload mismatch (got {responseBytes.Length} bytes)"); + } + catch (Exception ex) + { + return (index, false, ex.Message); + } + }); + } + + var results = await Task.WhenAll(tasks); + + var failures = results.Where(r => !r.success).ToArray(); + Assert.Empty(failures); + } + + [Fact(Timeout = 60000)] + public async Task ConnectionWindowStarvation_should_distribute_bandwidth_across_streams() + { + const int concurrentRequests = 8; + const int payloadSize = 256 * 1024; + var payloads = new byte[concurrentRequests][]; + var completionOrder = new List(); + + for (var i = 0; i < concurrentRequests; i++) + { + payloads[i] = new byte[payloadSize]; + RandomNumberGenerator.Fill(payloads[i]); + } + + var tasks = new Task<(int index, bool success)>[concurrentRequests]; + + for (var i = 0; i < concurrentRequests; i++) + { + var index = i; + tasks[i] = Task.Run(async () => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payloads[index]) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (index, false); + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + lock (completionOrder) + { + completionOrder.Add(index); + } + + return (index, payloads[index].SequenceEqual(responseBytes)); + }); + } + + var results = await Task.WhenAll(tasks); + + Assert.True(results.All(r => r.success), "All streams must complete successfully"); + Assert.Equal(concurrentRequests, completionOrder.Count); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/DataRateEnforcementSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/DataRateEnforcementSpec.cs new file mode 100644 index 000000000..37c24b69d --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/DataRateEnforcementSpec.cs @@ -0,0 +1,216 @@ +using System.Net; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H2; + +[Collection("H2")] +public sealed class DataRateEnforcementSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version20; + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-bytes", async ctx => + { + using var stream = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); + var data = stream.ToArray(); + ctx.Response.ContentType = "application/octet-stream"; + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); + }); + + app.MapGet("/generate", async ctx => + { + var size = int.Parse(ctx.Request.Query["size"]!); + ctx.Response.ContentType = "application/octet-stream"; + var buffer = new byte[16 * 1024]; + Array.Fill(buffer, (byte)0xAB); + var remaining = size; + while (remaining > 0) + { + var toWrite = Math.Min(buffer.Length, remaining); + await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), ctx.RequestAborted); + remaining -= toWrite; + } + }); + } + + [Fact(Timeout = 30000)] + public async Task DataRateEnforcement_should_not_kill_streams_under_normal_concurrent_load() + { + const int concurrentRequests = 10; + const int payloadSize = 256 * 1024; + var payloads = new byte[concurrentRequests][]; + + for (var i = 0; i < concurrentRequests; i++) + { + payloads[i] = new byte[payloadSize]; + RandomNumberGenerator.Fill(payloads[i]); + } + + var tasks = new Task<(int index, bool success, string error)>[concurrentRequests]; + + for (var i = 0; i < concurrentRequests; i++) + { + var index = i; + tasks[i] = Task.Run(async () => + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payloads[index]) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (index, false, $"Status: {response.StatusCode}"); + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + return responseBytes.Length == payloads[index].Length && payloads[index].SequenceEqual(responseBytes) + ? (index, true, "") + : (index, false, "Payload mismatch"); + } + catch (Exception ex) + { + return (index, false, ex.Message); + } + }); + } + + var results = await Task.WhenAll(tasks); + + var failures = results.Where(r => !r.success).ToArray(); + Assert.Empty(failures); + } + + [Fact(Timeout = 30000)] + public async Task DataRateEnforcement_should_reset_stream_when_client_sends_below_minimum_rate() + { + var payload = new byte[64 * 1024]; + RandomNumberGenerator.Fill(payload); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new StreamContent(new ThrottledStream(payload, bytesPerChunk: 32, delayPerChunk: TimeSpan.FromMilliseconds(200))) + }; + + var ex = await Assert.ThrowsAnyAsync(async () => + { + var response = await Client.SendAsync(request, CancellationToken); + await response.Content.ReadAsByteArrayAsync(CancellationToken); + }); + + Assert.True( + ex is HttpRequestException or OperationCanceledException, + $"Expected HttpRequestException or OperationCanceledException, got {ex.GetType().Name}: {ex.Message}"); + } + + [Fact(Timeout = 30000)] + public async Task DataRateEnforcement_should_not_affect_fast_streams_when_slow_stream_is_killed() + { + var fastPayload = new byte[128 * 1024]; + RandomNumberGenerator.Fill(fastPayload); + var slowPayload = new byte[64 * 1024]; + RandomNumberGenerator.Fill(slowPayload); + + var slowTask = Task.Run(async () => + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new StreamContent(new ThrottledStream(slowPayload, bytesPerChunk: 32, delayPerChunk: TimeSpan.FromMilliseconds(200))) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + return (success: false, error: "Expected slow request to be reset"); + } + catch + { + return (success: true, error: ""); + } + }); + + await Task.Delay(100, CancellationToken); + + var fastTasks = new Task[5]; + for (var i = 0; i < fastTasks.Length; i++) + { + fastTasks[i] = Task.Run(async () => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(fastPayload) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + if (response.StatusCode != HttpStatusCode.OK) + { + return false; + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + return fastPayload.SequenceEqual(responseBytes); + }); + } + + var fastResults = await Task.WhenAll(fastTasks); + Assert.True(fastResults.All(r => r), "Fast streams should not be affected by slow stream enforcement"); + + var slowResult = await slowTask; + Assert.True(slowResult.success, slowResult.error); + } + + private sealed class ThrottledStream(byte[] data, int bytesPerChunk, TimeSpan delayPerChunk) : Stream + { + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => data.Length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_position >= data.Length) + { + return 0; + } + + await Task.Delay(delayPerChunk, cancellationToken); + + var toRead = Math.Min(bytesPerChunk, Math.Min(buffer.Length, data.Length - _position)); + data.AsMemory(_position, toRead).CopyTo(buffer); + _position += toRead; + return toRead; + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= data.Length) + { + return 0; + } + + Thread.Sleep(delayPerChunk); + + var toRead = Math.Min(bytesPerChunk, Math.Min(count, data.Length - _position)); + data.AsSpan(_position, toRead).CopyTo(buffer.AsSpan(offset, toRead)); + _position += toRead; + return toRead; + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/DefaultSettingsSmokeSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/DefaultSettingsSmokeSpec.cs new file mode 100644 index 000000000..92114ac77 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/DefaultSettingsSmokeSpec.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H2; + +[Collection("H2")] +public sealed class DefaultSettingsSmokeSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version20; + + // Concurrent large transfers can exceed the 10s default under CI contention; + // stay below the tightest (30s) watchdog in this spec. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(25); + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-bytes", async ctx => + { + using var ms = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(ms, ctx.RequestAborted); + var data = ms.ToArray(); + ctx.Response.ContentType = "application/octet-stream"; + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); + }); + + app.MapGet("/generate", async ctx => + { + var size = int.Parse(ctx.Request.Query["size"]!); + ctx.Response.ContentType = "application/octet-stream"; + var buffer = new byte[64 * 1024]; + Array.Fill(buffer, (byte)0xCD); + var remaining = size; + while (remaining > 0) + { + var toWrite = Math.Min(buffer.Length, remaining); + await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), ctx.RequestAborted); + remaining -= toWrite; + } + }); + } + + [Fact(Timeout = 60000)] + public async Task Defaults_should_handle_concurrent_POST_echo_without_rate_violations() + { + const int concurrentRequests = 10; + const int payloadSize = 512 * 1024; + var payloads = new byte[concurrentRequests][]; + + for (var i = 0; i < concurrentRequests; i++) + { + payloads[i] = new byte[payloadSize]; + RandomNumberGenerator.Fill(payloads[i]); + } + + var tasks = new Task<(int index, bool success, string error)>[concurrentRequests]; + + for (var i = 0; i < concurrentRequests; i++) + { + var index = i; + tasks[i] = Task.Run(async () => + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payloads[index]) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (index, false, $"Status: {response.StatusCode}"); + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + return payloads[index].SequenceEqual(responseBytes) + ? (index, true, "") + : (index, false, $"Payload mismatch (got {responseBytes.Length} bytes)"); + } + catch (Exception ex) + { + return (index, false, ex.Message); + } + }); + } + + var results = await Task.WhenAll(tasks); + + var failures = results.Where(r => !r.success).ToArray(); + Assert.Empty(failures); + } + + [Fact(Timeout = 30000)] + public async Task Defaults_should_stream_large_response_with_adaptive_scaling() + { + const int responseSize = 4 * 1024 * 1024; + + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/generate?size={responseSize}"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsByteArrayAsync(CancellationToken); + Assert.Equal(responseSize, body.Length); + Assert.True(body.All(b => b == 0xCD)); + } + + [Fact(Timeout = 60000)] + public async Task Defaults_should_handle_concurrent_large_responses_with_data_rate_active() + { + const int concurrentRequests = 5; + const int responseSize = 2 * 1024 * 1024; + + var tasks = new Task<(bool success, int length)>[concurrentRequests]; + + for (var i = 0; i < concurrentRequests; i++) + { + tasks[i] = Task.Run(async () => + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/generate?size={responseSize}"); + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (false, 0); + } + + var body = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + return body.Length == responseSize && body.All(b => b == 0xCD) + ? (true, body.Length) + : (false, body.Length); + }); + } + + var results = await Task.WhenAll(tasks); + + Assert.True(results.All(r => r.success), + $"Failures: {string.Join(", ", results.Where(r => !r.success).Select(r => $"len={r.length}"))}"); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/FlowControlSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/FlowControlSpec.cs index af521168f..f7f1b03ee 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H2/FlowControlSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/FlowControlSpec.cs @@ -15,10 +15,10 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/echo-bytes", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var data = stream.ToArray(); ctx.Response.ContentType = "application/octet-stream"; - await ctx.Response.Body.WriteAsync(data, CancellationToken); + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); }); app.MapGet("/generate-large", async ctx => @@ -28,7 +28,7 @@ protected override void ConfigureEndpoints(WebApplication app) Array.Fill(buffer, (byte)0xCD); for (var i = 0; i < 64; i++) { - await ctx.Response.Body.WriteAsync(buffer, CancellationToken); + await ctx.Response.Body.WriteAsync(buffer, ctx.RequestAborted); } }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/HandlerMiddlewareSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/HandlerMiddlewareSpec.cs new file mode 100644 index 000000000..8ab0d946b --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/HandlerMiddlewareSpec.cs @@ -0,0 +1,148 @@ +using System.Net; +using Akka.Actor; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H2; + +[Collection("H2")] +public sealed class HandlerMiddlewareSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version20; + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/ping", () => Results.Ok("pong")); + + app.MapGet("/echo-headers", (HttpContext ctx) => + { + var injected = ctx.Request.Headers["X-Handler-Injected"].ToString(); + var response = new Dictionary + { + ["x-handler-injected"] = injected + }; + return Results.Ok(response); + }); + } + + private sealed class HeaderInjectionHandler : TurboHandler + { + public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) + { + request.Headers.TryAddWithoutValidation("X-Handler-Injected", "success"); + return request; + } + } + + private sealed class FailingHandler : TurboHandler + { + public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) + { + throw new InvalidOperationException("Handler intentionally throwing"); + } + } + + private sealed class ConditionalFailingHandler : TurboHandler + { + public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) + { + if (request.Headers.Contains("X-Fail")) + { + throw new InvalidOperationException("Conditional handler failure"); + } + return request; + } + } + + [Fact(Timeout = 10000)] + public async Task Handler_should_inject_request_headers_that_reach_server() + { + // Create a separate client with header-injecting handler + var system = await GetActorSystemAsync(); + var client = CreateClientWithHandler(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/echo-headers"); + var response = await client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Contains("success", body); + } + + [Fact(Timeout = 10000)] + public async Task Handler_should_fail_per_request_when_throwing() + { + var client = CreateClientWithHandler(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + var ex = await Assert.ThrowsAsync(() => client.SendAsync(request, CancellationToken)); + Assert.Contains("Handler intentionally throwing", ex.Message); + } + + [Fact(Timeout = 10000)] + public async Task Handler_should_fail_only_faulted_request_while_others_succeed() + { + var client = CreateClientWithHandler(); + + // Send a failing request + var failingRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + failingRequest.Headers.Add("X-Fail", "yes"); + + // Send a good request + var goodRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + + // Execute them sequentially to test per-request isolation + var failTask = client.SendAsync(failingRequest, CancellationToken); + + // This should throw + _ = await Assert.ThrowsAsync(() => failTask); + + // Now send the good request — it should succeed despite the handler + var goodResponse = await client.SendAsync(goodRequest, CancellationToken); + Assert.Equal(HttpStatusCode.OK, goodResponse.StatusCode); + } + + private ITurboHttpClient CreateClientWithHandler() where THandler : TurboHandler + { + var services = new ServiceCollection(); + services.AddSingleton(GetActorSystemAsync().Result); + + var clientOptions = new TurboClientOptions + { + BaseAddress = new Uri(BaseUri), + DangerousAcceptAnyServerCertificate = true + }; + + services.AddTurboHttpClient() + .AddHandler(); + + services.Replace(ServiceDescriptor.Singleton>( + new FixedOptionsFactory(clientOptions))); + + var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var client = factory.CreateClient(string.Empty); + client.DefaultRequestVersion = ProtocolVersion; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + client.Timeout = TimeSpan.FromSeconds(10); + + return client; + } + + private async Task GetActorSystemAsync() + { + // Create a minimal ActorSystem for the test client + var setup = BootstrapSetup.Create(); + return await Task.FromResult(ActorSystem.Create($"test-handler-{Guid.NewGuid():N}", setup)); + } + + private sealed class FixedOptionsFactory(TurboClientOptions options) : IOptionsFactory + { + public TurboClientOptions Create(string name) => options; + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/LargePayloadSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/LargePayloadSpec.cs index 6b8ae91c1..9eed952ef 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H2/LargePayloadSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/LargePayloadSpec.cs @@ -11,15 +11,19 @@ public sealed class LargePayloadSpec : End2EndSpecBase { protected override Version ProtocolVersion => HttpVersion.Version20; + // Under cross-module CPU contention on small CI runners even cheap requests can + // stall past the 10s default; stay below the 30s watchdogs instead. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(25); + protected override void ConfigureEndpoints(WebApplication app) { app.MapPost("/echo-bytes", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var data = stream.ToArray(); ctx.Response.ContentType = "application/octet-stream"; - await ctx.Response.Body.WriteAsync(data, CancellationToken); + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); }); app.MapGet("/generate", async (int size, HttpContext ctx) => @@ -31,7 +35,7 @@ protected override void ConfigureEndpoints(WebApplication app) while (remaining > 0) { var toWrite = Math.Min(1024, remaining); - await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), CancellationToken); + await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), ctx.RequestAborted); remaining -= toWrite; } }); @@ -39,10 +43,10 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/empty-echo", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var length = stream.Length; ctx.Response.ContentType = "text/plain"; - await ctx.Response.WriteAsync(length.ToString(), CancellationToken); + await ctx.Response.WriteAsync(length.ToString(), ctx.RequestAborted); }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/MultiplexingSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/MultiplexingSpec.cs index e6760595c..b5c05c22b 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H2/MultiplexingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/MultiplexingSpec.cs @@ -15,9 +15,9 @@ protected override void ConfigureEndpoints(WebApplication app) { app.MapGet("/id/{id}", (int id) => Results.Ok(id)); - app.MapGet("/delay/{ms}", async (int ms) => + app.MapGet("/delay/{ms}", async (int ms, HttpContext ctx) => { - await Task.Delay(ms, CancellationToken); + await Task.Delay(ms, ctx.RequestAborted); return Results.Ok(ms); }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/PatternedPayloadIntegritySpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/PatternedPayloadIntegritySpec.cs new file mode 100644 index 000000000..f530e9d9f --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/PatternedPayloadIntegritySpec.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Servus.Akka.Transport; +using TurboHTTP.Server; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H2; + +/// +/// Regression spec for the QueuedBodyReader cross-thread race that intermittently lost, +/// duplicated, or reordered whole 16KB DATA frames under concurrent multiplexed streams. +/// Payload bytes encode (stream, 16KB-block), so any integrity failure reports exactly +/// which blocks were lost/reordered and on which side (request vs response) it happened. +/// Runs over h2c so the TLS layer is out of the picture. +/// +[Collection("H2")] +public sealed class PatternedPayloadIntegritySpec : End2EndSpecBase +{ + private const int BlockSize = 16 * 1024; + + protected override Version ProtocolVersion => HttpVersion.Version20; + + protected override bool UseTls => false; + + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(45); + + protected override void ConfigureServer(TurboServerOptions options, ushort port, System.Security.Cryptography.X509Certificates.X509Certificate2? cert) + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-bytes", async ctx => + { + using var stream = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); + var data = stream.ToArray(); + ctx.Response.ContentType = "application/octet-stream"; + ctx.Response.Headers["X-Received-Length"] = data.Length.ToString(); + ctx.Response.Headers["X-Request-Analysis"] = Analyze(data); + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); + }); + } + + private static byte[] BuildPayload(int stream, int size) + { + var data = new byte[size]; + for (var p = 0; p < size; p++) + { + var block = p / BlockSize; + data[p] = (p % 4) switch + { + 0 => 0xA0, + 1 => (byte)stream, + 2 => (byte)block, + _ => (byte)(block >> 8) + }; + } + + return data; + } + + // Walks 4-byte words and summarizes the observed (stream, block) sequence as runs. + private static string Analyze(byte[] data) + { + if (data.Length % 4 != 0) + { + return $"len={data.Length} (not word aligned)"; + } + + var sb = new StringBuilder(); + var runStream = -1; + var runStartBlock = -1; + var runEndBlock = -1; + + void FlushRun() + { + if (runStream < 0) + { + return; + } + + if (sb.Length > 0) + { + sb.Append(", "); + } + + sb.Append($"s{runStream}:b{runStartBlock}"); + if (runEndBlock != runStartBlock) + { + sb.Append($"-{runEndBlock}"); + } + } + + for (var p = 0; p < data.Length; p += 4) + { + if (data[p] != 0xA0) + { + FlushRun(); + runStream = -1; + if (sb.Length > 0) + { + sb.Append(", "); + } + + sb.Append($"GARBAGE@{p}"); + while (p + 4 < data.Length && data[p + 4] != 0xA0) + { + p += 4; + } + + continue; + } + + var s = data[p + 1]; + var b = data[p + 2] | (data[p + 3] << 8); + + if (s == runStream && (b == runEndBlock || b == runEndBlock + 1)) + { + runEndBlock = b; + } + else + { + FlushRun(); + runStream = s; + runStartBlock = b; + runEndBlock = b; + } + } + + FlushRun(); + return $"len={data.Length} [{sb}]"; + } + + [Fact(Timeout = 60000)] + public async Task Http2_should_roundtrip_concurrent_patterned_payloads_exactly() + { + const int concurrentRequests = 20; + const int payloadSize = 512 * 1024; + + var tasks = Enumerable.Range(0, concurrentRequests).Select(index => Task.Run(async () => + { + var payload = BuildPayload(index, payloadSize); + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payload) + }; + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + if (response.StatusCode != HttpStatusCode.OK) + { + return $"[{index}] status {response.StatusCode}"; + } + + var responseBytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + if (payload.SequenceEqual(responseBytes)) + { + return null; + } + + var requestAnalysis = response.Headers.TryGetValues("X-Request-Analysis", out var v) + ? string.Join(",", v) + : "?"; + return $"[{index}] RESPONSE {Analyze(responseBytes)} || REQUEST-AT-SERVER {requestAnalysis}"; + })).ToArray(); + + var results = await Task.WhenAll(tasks); + var failures = results.Where(r => r is not null).ToArray(); + Assert.True(failures.Length == 0, string.Join("\n", failures)); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/ResilienceSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/ResilienceSpec.cs index 5aed21c2b..10162e79a 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H2/ResilienceSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/ResilienceSpec.cs @@ -16,9 +16,9 @@ protected override void ConfigureEndpoints(WebApplication app) { app.MapGet("/fast", () => Results.Text("ok")); - app.MapGet("/slow", async () => + app.MapGet("/slow", async (HttpContext ctx) => { - await Task.Delay(30000, CancellationToken); + await Task.Delay(30000, ctx.RequestAborted); return Results.Ok("done"); }); diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/RoundtripSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/RoundtripSpec.cs index 3bbb3b72b..7833d8c06 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H2/RoundtripSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H2/RoundtripSpec.cs @@ -18,14 +18,14 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/echo", async (HttpContext ctx) => { using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); + var body = await reader.ReadToEndAsync(ctx.RequestAborted); return Results.Ok(body); }); app.MapPut("/put-echo", async (HttpContext ctx) => { using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); + var body = await reader.ReadToEndAsync(ctx.RequestAborted); return Results.Ok(body); }); diff --git a/src/TurboHTTP.IntegrationTests.End2End/H3/DiagnosticSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H3/DiagnosticSpec.cs new file mode 100644 index 000000000..a403e3f51 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H3/DiagnosticSpec.cs @@ -0,0 +1,58 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H3; + +[Collection("H3")] +public sealed class DiagnosticSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version30; + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/ping", () => Results.Text("pong")); + } + + [Fact(Timeout = 15000)] + public async Task A_first_test_turbo_client() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + var response = await Client.SendAsync(request, CancellationToken); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + + await Console.Error.WriteLineAsync($"[DIAG-A] TurboClient: status={response.StatusCode} body='{body}'"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("pong", body); + } + + [Fact(Timeout = 15000)] + public async Task B_second_test_turbo_client() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); + var response = await Client.SendAsync(request, CancellationToken); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + + await Console.Error.WriteLineAsync($"[DIAG-B] TurboClient: status={response.StatusCode} body='{body}'"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("pong", body); + } + + [Fact(Timeout = 15000)] + public async Task C_third_test_dotnet_client() + { + using var handler = new HttpClientHandler(); + handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; + using var dotnetClient = new HttpClient(handler); + dotnetClient.DefaultRequestVersion = HttpVersion.Version30; + dotnetClient.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + + var response = await dotnetClient.GetAsync($"{BaseUri}/ping", CancellationToken); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + + await Console.Error.WriteLineAsync($"[DIAG-C] .NET Client: status={response.StatusCode} body='{body}'"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("pong", body); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H3/FlowControlSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H3/FlowControlSpec.cs new file mode 100644 index 000000000..553a79d96 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H3/FlowControlSpec.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using TurboHTTP.IntegrationTests.End2End.Shared; + +namespace TurboHTTP.IntegrationTests.End2End.H3; + +[Collection("H3")] +public sealed class FlowControlSpec : End2EndSpecBase +{ + protected override Version ProtocolVersion => HttpVersion.Version30; + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-bytes", async ctx => + { + using var stream = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); + var data = stream.ToArray(); + ctx.Response.ContentType = "application/octet-stream"; + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); + }); + + app.MapGet("/generate-large", async ctx => + { + ctx.Response.ContentType = "application/octet-stream"; + var buffer = new byte[16 * 1024]; + Array.Fill(buffer, (byte)0xCD); + for (var i = 0; i < 64; i++) + { + await ctx.Response.Body.WriteAsync(buffer, ctx.RequestAborted); + } + }); + } + + [Fact(Timeout = 30000)] + public async Task FlowControl_should_transfer_large_body_under_backpressure() + { + var payload = new byte[512 * 1024]; + RandomNumberGenerator.Fill(payload); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUri}/echo-bytes") + { + Content = new ByteArrayContent(payload) + }; + + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseBytes = await response.Content.ReadAsByteArrayAsync(CancellationToken); + Assert.Equal(payload, responseBytes); + } + + [Fact(Timeout = 30000)] + public async Task FlowControl_should_receive_large_server_generated_response() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/generate-large"); + + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseBytes = await response.Content.ReadAsByteArrayAsync(CancellationToken); + var expectedSize = 64 * 16 * 1024; + Assert.Equal(expectedSize, responseBytes.Length); + Assert.True(responseBytes.All(b => b == 0xCD)); + } +} diff --git a/src/TurboHTTP.IntegrationTests.End2End/H3/LargePayloadSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H3/LargePayloadSpec.cs index 5b10c81ad..6c152c7cf 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H3/LargePayloadSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H3/LargePayloadSpec.cs @@ -11,15 +11,19 @@ public sealed class LargePayloadSpec : End2EndSpecBase { protected override Version ProtocolVersion => HttpVersion.Version30; + // Under cross-module CPU contention on small CI runners even cheap requests can + // stall past the 10s default; stay below the 30s watchdogs instead. + protected override TimeSpan ClientTimeout => TimeSpan.FromSeconds(25); + protected override void ConfigureEndpoints(WebApplication app) { app.MapPost("/echo-bytes", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var data = stream.ToArray(); ctx.Response.ContentType = "application/octet-stream"; - await ctx.Response.Body.WriteAsync(data, CancellationToken); + await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted); }); app.MapGet("/generate", async (int size, HttpContext ctx) => @@ -31,7 +35,7 @@ protected override void ConfigureEndpoints(WebApplication app) while (remaining > 0) { var toWrite = Math.Min(1024, remaining); - await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), CancellationToken); + await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, toWrite), ctx.RequestAborted); remaining -= toWrite; } }); @@ -39,10 +43,10 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/empty-echo", async ctx => { using var stream = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(stream, CancellationToken); + await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted); var length = stream.Length; ctx.Response.ContentType = "text/plain"; - await ctx.Response.WriteAsync(length.ToString(), CancellationToken); + await ctx.Response.WriteAsync(length.ToString(), ctx.RequestAborted); }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H3/MultiplexingSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H3/MultiplexingSpec.cs index ead05afb5..1fbd8e537 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H3/MultiplexingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H3/MultiplexingSpec.cs @@ -15,9 +15,9 @@ protected override void ConfigureEndpoints(WebApplication app) { app.MapGet("/id/{id:int}", (int id) => Results.Ok(id)); - app.MapGet("/delay/{ms:int}", async (int ms) => + app.MapGet("/delay/{ms:int}", async (int ms, HttpContext ctx) => { - await Task.Delay(ms, CancellationToken); + await Task.Delay(ms, ctx.RequestAborted); return Results.Ok(ms); }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/H3/ResilienceSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H3/ResilienceSpec.cs index 615efad05..0f655fc15 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H3/ResilienceSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H3/ResilienceSpec.cs @@ -16,9 +16,9 @@ protected override void ConfigureEndpoints(WebApplication app) { app.MapGet("/fast", () => Results.Text("ok")); - app.MapGet("/slow", async () => + app.MapGet("/slow", async (HttpContext ctx) => { - await Task.Delay(30000, CancellationToken); + await Task.Delay(30000, ctx.RequestAborted); return Results.Ok("done"); }); diff --git a/src/TurboHTTP.IntegrationTests.End2End/H3/RoundtripSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H3/RoundtripSpec.cs index 34e0854f5..7b880d277 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/H3/RoundtripSpec.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/H3/RoundtripSpec.cs @@ -18,7 +18,7 @@ protected override void ConfigureEndpoints(WebApplication app) app.MapPost("/echo", async (HttpContext ctx) => { using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); + var body = await reader.ReadToEndAsync(ctx.RequestAborted); return Results.Ok(body); }); } diff --git a/src/TurboHTTP.IntegrationTests.End2End/Shared/End2EndSpecBase.cs b/src/TurboHTTP.IntegrationTests.End2End/Shared/End2EndSpecBase.cs index d645d481d..5a353c340 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/Shared/End2EndSpecBase.cs +++ b/src/TurboHTTP.IntegrationTests.End2End/Shared/End2EndSpecBase.cs @@ -1,12 +1,13 @@ using System.Net; using System.Net.Quic; using System.Net.Security; -using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Akka.Actor; using Akka.DependencyInjection; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -20,6 +21,11 @@ namespace TurboHTTP.IntegrationTests.End2End.Shared; public abstract class End2EndSpecBase : IAsyncLifetime { + // One RSA keygen per process instead of one per TLS test — keygen is CPU-heavy + // and amplifies starvation when collections run in parallel on small CI runners. + private static readonly Lazy SharedCertificate = + new(() => CreateSelfSignedCertificate("127.0.0.1"), LazyThreadSafetyMode.ExecutionAndPublication); + private WebApplication? _app; private ITurboHttpClient? _client; private Microsoft.Extensions.DependencyInjection.ServiceProvider? _clientProvider; @@ -72,6 +78,13 @@ protected virtual void ConfigureClientOptions(TurboClientOptions options) { } + /// + /// Global client timeout. Keep the default low — several specs rely on it as a backstop + /// well below their watchdogs. Bulk-transfer stress specs override this with a higher + /// value so legitimate slow transfers under CI contention don't trip it. + /// + protected virtual TimeSpan ClientTimeout => TimeSpan.FromSeconds(10); + protected ITurboHttpClient Client => _client!; protected string BaseUri { get; private set; } = string.Empty; @@ -85,29 +98,30 @@ public async ValueTask InitializeAsync() Assert.Skip("QUIC not available on this platform"); } - var port = GetFreePort(); var needsTls = UseTls; if (needsTls) { - _cert = CreateSelfSignedCertificate("127.0.0.1"); + _cert = SharedCertificate.Value; } - var scheme = needsTls ? "https" : "http"; - BaseUri = $"{scheme}://127.0.0.1:{port}"; - var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); + // Bind port 0 and read the real port back after start — probing for a free + // port and rebinding it races with parallel tests (and parallel test modules). builder.Host.UseTurboHttp(options => { - ConfigureServer(options, port, _cert); + ConfigureServer(options, 0, _cert); }); _app = builder.Build(); ConfigureEndpoints(_app); await _app.StartAsync(); + var scheme = needsTls ? "https" : "http"; + BaseUri = $"{scheme}://127.0.0.1:{ResolveBoundPort(_app)}"; + var services = new ServiceCollection(); var diSetup = DependencyResolverSetup.Create(services.BuildServiceProvider()); @@ -133,7 +147,7 @@ public async ValueTask InitializeAsync() _client = factory.CreateClient(string.Empty); _client.DefaultRequestVersion = ProtocolVersion; _client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; - _client.Timeout = TimeSpan.FromSeconds(10); + _client.Timeout = ClientTimeout; } public virtual async ValueTask DisposeAsync() @@ -157,8 +171,6 @@ public virtual async ValueTask DisposeAsync() await _clientProvider.DisposeAsync(); } - - _cert?.Dispose(); } protected static X509Certificate2 CreateSelfSignedCertificate(string cn) @@ -188,13 +200,12 @@ protected static X509Certificate2 CreateSelfSignedCertificate(string cn) X509KeyStorageFlags.Exportable); } - private static ushort GetFreePort() + private static int ResolveBoundPort(WebApplication app) { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return (ushort)port; + var addresses = app.Services.GetRequiredService() + .Features.Get()! + .Addresses; + return new Uri(addresses.First()).Port; } private sealed class FixedOptionsFactory(TurboClientOptions options) : IOptionsFactory diff --git a/src/TurboHTTP.IntegrationTests.End2End/TurboHTTP.IntegrationTests.End2End.csproj b/src/TurboHTTP.IntegrationTests.End2End/TurboHTTP.IntegrationTests.End2End.csproj index 28f4fa6da..80cab4fda 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/TurboHTTP.IntegrationTests.End2End.csproj +++ b/src/TurboHTTP.IntegrationTests.End2End/TurboHTTP.IntegrationTests.End2End.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/TurboHTTP.IntegrationTests.End2End/xunit.runner.json b/src/TurboHTTP.IntegrationTests.End2End/xunit.runner.json index 4c6a0fdf5..1a57b530a 100644 --- a/src/TurboHTTP.IntegrationTests.End2End/xunit.runner.json +++ b/src/TurboHTTP.IntegrationTests.End2End/xunit.runner.json @@ -1,4 +1,6 @@ { "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeTestCollections": true + "parallelizeTestCollections": true, + "parallelizeAssembly": false, + "maxParallelThreads": 2 } diff --git a/src/TurboHTTP.IntegrationTests.Server/BodyFloodReproSpec.cs b/src/TurboHTTP.IntegrationTests.Server/BodyFloodReproSpec.cs new file mode 100644 index 000000000..9e7ce78c5 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/BodyFloodReproSpec.cs @@ -0,0 +1,106 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Servus.Akka.Transport; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +[Collection("ServerStress")] +public sealed class BodyFloodReproSpec : ServerSpecBase +{ + private static readonly byte[] Payload = new byte[1 * 1024 * 1024]; + + protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) + { + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); + }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-size", async ctx => + { + long count = 0; + var buffer = new byte[64 * 1024]; + int read; + while ((read = await ctx.Request.Body.ReadAsync(buffer, CancellationToken)) > 0) + { + count += read; + } + + ctx.Response.ContentType = "text/plain"; + await ctx.Response.WriteAsync(count.ToString(), CancellationToken); + }); + } + + [Fact(Timeout = 10000)] + public async Task Post_1mb_body_should_return_correct_size() + { + var content = new ByteArrayContent(Payload); + var response = await Client.PostAsync( + new Uri($"http://127.0.0.1:{Port}/echo-size"), + content, + CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Equal((1 * 1024 * 1024).ToString(), body); + } + + [Fact(Timeout = 120000)] + public async Task Concurrent_1mb_posts_should_all_succeed() + { + const int concurrency = 50; + using var handler = new SocketsHttpHandler(); + handler.MaxConnectionsPerServer = concurrency; + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(60); + + var uri = new Uri($"http://127.0.0.1:{Port}/echo-size"); + var errors = new List(); + var succeeded = 0; + + var expectedSize = (1 * 1024 * 1024).ToString(); + var tasks = Enumerable.Range(0, concurrency).Select(async i => + { + try + { + var content = new ByteArrayContent(Payload); + var response = await client.PostAsync(uri, content, CancellationToken); + if (response.StatusCode != HttpStatusCode.OK) + { + lock (errors) errors.Add($"[{i}] status={response.StatusCode}"); + return; + } + + var body = await response.Content.ReadAsStringAsync(CancellationToken); + if (body == expectedSize) + { + Interlocked.Increment(ref succeeded); + } + else + { + lock (errors) errors.Add($"[{i}] body size mismatch: expected={expectedSize}, actual={body}"); + } + } + catch (Exception ex) + { + lock (errors) errors.Add($"[{i}] {ex.GetType().Name}: {ex.InnerException?.Message ?? ex.Message}"); + } + }).ToArray(); + + await Task.WhenAll(tasks); + + var msg = $"{succeeded}/{concurrency} succeeded"; + if (errors.Count > 0) + { + msg += $"\nErrors ({errors.Count}):\n" + string.Join("\n", errors.Take(10)); + } + + Assert.True(succeeded == concurrency, msg); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/ConnectionCloseReproSpec.cs b/src/TurboHTTP.IntegrationTests.Server/ConnectionCloseReproSpec.cs new file mode 100644 index 000000000..c37f92dd4 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/ConnectionCloseReproSpec.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Servus.Akka.Transport; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +[Collection("Infrastructure")] +public sealed class ConnectionCloseReproSpec : ServerSpecBase +{ + protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) + { + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); + }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/ping", () => Results.Content("pong", "text/plain")); + } + + [Fact(Timeout = 15000)] + public async Task New_connection_after_graceful_close_should_succeed() + { + var uri = new Uri($"http://127.0.0.1:{Port}/ping"); + + using (var client1 = new HttpClient()) + { + var r1 = await client1.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r1.StatusCode); + } + + await Task.Delay(500, CancellationToken); + + using var client2 = new HttpClient(); + var r2 = await client2.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r2.StatusCode); + } + + [Fact(Timeout = 15000)] + public async Task New_connection_after_tcp_rst_should_succeed() + { + using (var socket = new TcpClient()) + { + await socket.ConnectAsync("127.0.0.1", Port, CancellationToken); + socket.LingerState = new LingerOption(true, 0); + } + + await Task.Delay(500, CancellationToken); + + var uri = new Uri($"http://127.0.0.1:{Port}/ping"); + using var client = new HttpClient(); + var r = await client.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r.StatusCode); + } + + [Fact(Timeout = 15000)] + public async Task New_connection_after_request_and_rst_should_succeed() + { + var uri = new Uri($"http://127.0.0.1:{Port}/ping"); + + using (var client1 = new HttpClient(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.Zero + })) + { + var r1 = await client1.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r1.StatusCode); + } + + using (var socket = new TcpClient()) + { + await socket.ConnectAsync("127.0.0.1", Port, CancellationToken); + socket.LingerState = new LingerOption(true, 0); + } + + await Task.Delay(200, CancellationToken); + + using var client2 = new HttpClient(); + var r2 = await client2.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r2.StatusCode); + } +} \ No newline at end of file diff --git a/src/TurboHTTP.IntegrationTests.Server/DynamicPortSpec.cs b/src/TurboHTTP.IntegrationTests.Server/DynamicPortSpec.cs new file mode 100644 index 000000000..c16c95efb --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/DynamicPortSpec.cs @@ -0,0 +1,92 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +public sealed class DynamicPortSpec : IAsyncLifetime +{ + private WebApplication? _app; + private HttpClient? _client; + + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + + builder.Host.UseTurboHttp(options => + { + options.Listen(IPAddress.Loopback, 0, lo => + lo.Protocols = HttpProtocols.Http1); + }); + + _app = builder.Build(); + _app.MapGet("/ping", () => Results.Content("pong", "text/plain")); + await _app.StartAsync(); + _client = new HttpClient(); + } + + public async ValueTask DisposeAsync() + { + _client?.Dispose(); + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + [Fact(Timeout = 10000)] + public void Address_feature_should_report_non_zero_port() + { + var addresses = _app!.Services.GetRequiredService() + .Features.Get()! + .Addresses + .ToArray(); + + Assert.Single(addresses); + var uri = new Uri(addresses[0]); + Assert.NotEqual(0, uri.Port); + } + + [Fact(Timeout = 10000)] + public async Task Request_to_dynamic_port_should_succeed() + { + var addresses = _app!.Services.GetRequiredService() + .Features.Get()! + .Addresses + .ToArray(); + + var baseUri = new Uri(addresses[0]); + var response = await _client!.GetAsync(new Uri(baseUri, "/ping"), CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Equal("pong", body); + } + + [Fact(Timeout = 30000)] + public async Task Multiple_requests_to_dynamic_port_should_all_succeed() + { + var addresses = _app!.Services.GetRequiredService() + .Features.Get()! + .Addresses + .ToArray(); + + var baseUri = new Uri(addresses[0]); + + var tasks = Enumerable.Range(0, 10) + .Select(_ => _client!.GetAsync(new Uri(baseUri, "/ping"), CancellationToken)); + + var responses = await Task.WhenAll(tasks); + + Assert.All(responses, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode)); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/H2ConcurrentReproSpec.cs b/src/TurboHTTP.IntegrationTests.Server/H2ConcurrentReproSpec.cs new file mode 100644 index 000000000..4beff68ab --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/H2ConcurrentReproSpec.cs @@ -0,0 +1,87 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +/// +/// Regression test for the H2 concurrent request deadlock (now fixed via per-connection bridge). +/// Verifies that 64+ concurrent H2 GET requests over a single multiplexed connection complete +/// without hanging. Was previously caused by the shared MergeHub/DynamicHub pipeline deadlocking +/// under back-pressure. +/// +[Collection("ServerStress")] +public sealed class H2ConcurrentReproSpec : MultiProtocolTlsServerSpecBase +{ + protected override HttpProtocols ServerProtocols => HttpProtocols.Http2; + + protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) + { + var certificate = CreateSelfSignedCertificate("localhost"); + builder.Host.UseTurboHttp(options => + { + options.ListenLocalhost(port, listen => + { + listen.UseHttps(certificate); + listen.Protocols = ServerProtocols; + }); + + // Allow far more concurrent streams than our test concurrency + // to ensure we don't hit the per-connection stream limit. + options.Http2.MaxConcurrentStreams = 128; + }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/ping/{id:int}", (int id) => Results.Ok(id)); + } + + /// + /// Sends 64 concurrent H2 GET requests on one connection and asserts all + /// respond within a reasonable time. Measures per-request latency to diagnose + /// pipeline bottlenecks. + /// + [Fact(Timeout = 10000)] + public async Task H2_should_process_64_concurrent_requests_on_one_connection() + { + const int concurrency = 64; + + using var handler = new SocketsHttpHandler + { + SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true }, + MaxConnectionsPerServer = 1 + }; + using var client = new HttpClient(handler) + { + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(8) + }; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var tasks = Enumerable.Range(0, concurrency).Select(async i => + { + var response = await client.GetAsync( + new Uri($"https://127.0.0.1:{Port}/ping/{i}"), + CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpVersion.Version20, response.Version); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + return System.Text.Json.JsonSerializer.Deserialize(body); + }).ToArray(); + + var results = await Task.WhenAll(tasks); + sw.Stop(); + + var sorted = results.Order().ToArray(); + Assert.Equal(Enumerable.Range(0, concurrency).ToArray(), sorted); + + TestContext.Current.SendDiagnosticMessage( + "H2 {0} concurrent requests completed in {1:N0} ms ({2:N0} req/s)", + concurrency, sw.ElapsedMilliseconds, + concurrency * 1000.0 / sw.ElapsedMilliseconds); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/HandlerTimeoutSpec.cs b/src/TurboHTTP.IntegrationTests.Server/HandlerTimeoutSpec.cs new file mode 100644 index 000000000..54301ac8f --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/HandlerTimeoutSpec.cs @@ -0,0 +1,92 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Servus.Akka.Transport; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +public sealed class HandlerTimeoutSpec : ServerSpecBase +{ + protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) + { + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); + options.HandlerTimeout = TimeSpan.FromMilliseconds(500); + options.HandlerGracePeriod = TimeSpan.FromMilliseconds(500); + }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/fast", () => Results.Ok("ok")); + + app.MapGet("/block-forever", async () => + { + await Task.Delay(Timeout.Infinite); + }); + + app.MapGet("/block-ignore-cancel", async () => + { + await Task.Delay(TimeSpan.FromSeconds(30)); + }); + + app.MapGet("/started-body-then-block", async ctx => + { + ctx.Response.ContentType = "text/plain"; + await ctx.Response.WriteAsync("partial"); + await ctx.Response.Body.FlushAsync(); + await Task.Delay(TimeSpan.FromSeconds(30)); + }); + } + + [Fact(Timeout = 10000)] + public async Task Fast_handler_should_return_200() + { + var response = await Client.GetAsync( + new Uri($"http://127.0.0.1:{Port}/fast"), + CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Timeout = 10000)] + public async Task Hard_timeout_should_return_503_when_headers_not_started() + { + var response = await Client.GetAsync( + new Uri($"http://127.0.0.1:{Port}/block-ignore-cancel"), + CancellationToken); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact(Timeout = 10000)] + public async Task Soft_timeout_cancels_handler_that_ignores_cancel() + { + var response = await Client.GetAsync( + new Uri($"http://127.0.0.1:{Port}/block-forever"), + CancellationToken); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact(Timeout = 10000)] + public async Task Hard_timeout_should_not_set_503_when_body_already_started() + { + try + { + var response = await Client.GetAsync( + new Uri($"http://127.0.0.1:{Port}/started-body-then-block"), + HttpCompletionOption.ResponseHeadersRead, + CancellationToken); + + Assert.NotEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + catch (HttpRequestException) + { + // Connection reset is acceptable — the key assertion is no 503 + } + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs index 371770144..8ae6ca64e 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting; +[Collection("Infrastructure")] public sealed class HttpsConnectionSpec : ServerSpecBase { protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/AlpnFallbackSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/AlpnFallbackSpec.cs new file mode 100644 index 000000000..47c2dfd14 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/AlpnFallbackSpec.cs @@ -0,0 +1,29 @@ +using System.Net; +using System.Text.Json; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; + +/// +/// Server advertises only HTTP/1.1. A client that prefers HTTP/2 must gracefully fall back +/// to HTTP/1.1 via ALPN rather than failing the handshake. +/// +[Collection("Infrastructure")] +public sealed class AlpnFallbackSpec : MultiProtocolTlsServerSpecBase +{ + protected override HttpProtocols ServerProtocols => HttpProtocols.Http1; + + [Fact(Timeout = 15000)] + public async Task Alpn_should_fall_back_to_http11_when_server_does_not_offer_h2() + { + using var client = CreateVersionedTlsClient(HttpVersion.Version20); + + var response = await client.GetAsync(Url("/protocol"), CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + var protocol = JsonSerializer.Deserialize(body); + Assert.Equal("HTTP/1.1", protocol); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/AlpnNegotiationSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/AlpnNegotiationSpec.cs new file mode 100644 index 000000000..f6845da7e --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/AlpnNegotiationSpec.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Text.Json; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; + +/// +/// Server advertises both HTTP/1.1 and HTTP/2 over TLS; the negotiated protocol must follow +/// what the client requests via ALPN. +/// +[Collection("Infrastructure")] +public sealed class AlpnNegotiationSpec : MultiProtocolTlsServerSpecBase +{ + protected override HttpProtocols ServerProtocols => HttpProtocols.Http1AndHttp2; + + private async Task GetNegotiatedProtocol(HttpClient client) + { + var response = await client.GetAsync(Url("/protocol"), CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + return JsonSerializer.Deserialize(body)!; + } + + [Fact(Timeout = 15000)] + public async Task Alpn_should_negotiate_http2_when_client_requests_h2() + { + using var client = CreateVersionedTlsClient(HttpVersion.Version20); + + var protocol = await GetNegotiatedProtocol(client); + + Assert.Equal("HTTP/2", protocol); + } + + [Fact(Timeout = 15000)] + public async Task Alpn_should_negotiate_http11_when_client_requests_h1_on_multi_protocol_server() + { + using var client = CreateVersionedTlsClient(HttpVersion.Version11); + + var protocol = await GetNegotiatedProtocol(client); + + Assert.Equal("HTTP/1.1", protocol); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs index bd804f21b..48bbaa07b 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs @@ -8,6 +8,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class ClientCertificateModeAllowSpec : ServerSpecBase { private X509Certificate2? _serverCert; diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs index 433c1893f..a0eaf5c4f 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs @@ -8,6 +8,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class ClientCertificateModeRequireSpec : ServerSpecBase { private X509Certificate2? _serverCert; diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/Http2ServerSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/Http2ServerSpec.cs new file mode 100644 index 000000000..79c8b86bd --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/Http2ServerSpec.cs @@ -0,0 +1,71 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; + +/// +/// Real HTTP/2 requests against TurboServer over TLS, driven by a neutral .NET HttpClient. +/// +[Collection("Infrastructure")] +public sealed class Http2ServerSpec : MultiProtocolTlsServerSpecBase +{ + protected override HttpProtocols ServerProtocols => HttpProtocols.Http1AndHttp2; + + protected override void ConfigureEndpoints(WebApplication app) + { + base.ConfigureEndpoints(app); + app.MapGet("/status/{code:int}", (int code) => Results.StatusCode(code)); + app.MapGet("/id/{id:int}", (int id) => Results.Ok(id)); + } + + [Fact(Timeout = 15000)] + public async Task Http2_should_echo_post_body_over_h2() + { + var payload = new string('x', 4 * 1024); + var request = NewRequest(HttpMethod.Post, "/echo"); + request.Content = new StringContent(payload); + + var response = await Client.SendAsync(request, CancellationToken); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpVersion.Version20, response.Version); + Assert.Equal(payload, System.Text.Json.JsonSerializer.Deserialize(body)); + } + + [Theory(Timeout = 15000)] + [InlineData(200)] + [InlineData(404)] + [InlineData(500)] + public async Task Http2_should_return_requested_status_code(int code) + { + var response = await Client.GetAsync(Url($"/status/{code}"), CancellationToken); + + Assert.Equal(HttpVersion.Version20, response.Version); + Assert.Equal(code, (int)response.StatusCode); + } + + [Fact(Timeout = 20000)] + public async Task Http2_should_multiplex_concurrent_requests_on_one_connection() + { + var tasks = Enumerable.Range(0, 20) + .Select(async i => + { + var response = await Client.GetAsync(Url($"/id/{i}"), CancellationToken); + Assert.Equal(HttpVersion.Version20, response.Version); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + return (i, value: System.Text.Json.JsonSerializer.Deserialize(body)); + }) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + foreach (var (i, value) in results) + { + Assert.Equal(i, value); + } + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/Http3ServerSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/Http3ServerSpec.cs new file mode 100644 index 000000000..ddd5e71d8 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/Http3ServerSpec.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Quic; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; +using QuicListenerOptionsServus = Servus.Akka.Transport.QuicListenerOptions; + +namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; + +/// +/// Real HTTP/3 (QUIC) requests against TurboServer, driven by a neutral .NET HttpClient. +/// Skipped on platforms without QUIC support. +/// +[Collection("Infrastructure")] +public sealed class Http3ServerSpec : MultiProtocolTlsServerSpecBase +{ + protected override HttpProtocols ServerProtocols => HttpProtocols.Http3; + + public override async ValueTask InitializeAsync() + { + if (!QuicConnection.IsSupported) + { + Assert.Skip("QUIC not supported on this platform"); + return; + } + + await base.InitializeAsync(); + } + + protected override void ConfigureListener(TurboServerOptions options, ushort port, X509Certificate2 certificate) + { + options.Bind(new QuicListenerOptionsServus + { + Host = "127.0.0.1", + Port = port, + ServerCertificate = certificate, + ApplicationProtocols = new List { SslApplicationProtocol.Http3 } + }); + } + + protected override HttpClient CreateHttpClient() => CreateExactVersionTlsClient(HttpVersion.Version30); + + protected override void ConfigureEndpoints(WebApplication app) + { + base.ConfigureEndpoints(app); + app.MapGet("/id/{id:int}", (int id) => Results.Ok(id)); + } + + [Fact(Timeout = 20000)] + public async Task Http3_should_serve_request_over_h3() + { + var response = await Client.GetAsync(Url("/protocol"), CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpVersion.Version30, response.Version); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Equal("HTTP/3", System.Text.Json.JsonSerializer.Deserialize(body)); + } + + [Fact(Timeout = 25000)] + public async Task Http3_should_multiplex_concurrent_requests_on_one_connection() + { + var tasks = Enumerable.Range(0, 15) + .Select(async i => + { + var response = await Client.GetAsync(Url($"/id/{i}"), CancellationToken); + Assert.Equal(HttpVersion.Version30, response.Version); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + return (i, value: System.Text.Json.JsonSerializer.Deserialize(body)); + }) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + foreach (var (i, value) in results) + { + Assert.Equal(i, value); + } + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs index be5aa39e4..d230a0b7c 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class SniCertSelectionSpec : ServerSpecBase { private X509Certificate2? _certA; diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs index d8ed79dab..0ffc11df6 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs @@ -8,6 +8,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class TlsHandshakeFeatureSpec : ServerSpecBase { protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) diff --git a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs index 83b79e4be..bc0c8e337 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Infrastructure; +[Collection("Infrastructure")] public sealed class ConnectionLimitSpec : ServerSpecBase { private readonly TaskCompletionSource _slot1Gate = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs index 2c3c443b4..f1702f216 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Infrastructure; +[Collection("Infrastructure")] public sealed class GracefulShutdownSpec : ServerSpecBase { private readonly TaskCompletionSource _handlerGate = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -37,15 +38,11 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync(); } - [Fact(Timeout = 20000)] + [Fact(Timeout = 30000)] public async Task Shutdown_should_complete_inflight_request() { - var handlerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var handlerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var testClient = new HttpClient(); - var request = testClient.GetAsync( - new Uri($"http://127.0.0.1:{Port}/slow"), + _ = testClient.GetAsync(new Uri($"http://127.0.0.1:{Port}/slow"), TestContext.Current.CancellationToken); await Task.Delay(100, TestContext.Current.CancellationToken); @@ -62,4 +59,4 @@ public async Task Shutdown_should_reject_new_connections() new Uri($"http://127.0.0.1:{Port}/fast"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } -} +} \ No newline at end of file diff --git a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs index 20e7bc037..cf50259b7 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Sockets; -using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Servus.Akka.Transport; @@ -9,6 +8,7 @@ namespace TurboHTTP.IntegrationTests.Server.Infrastructure; +[Collection("Infrastructure")] public sealed class TimeoutSpec : ServerSpecBase { protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) @@ -49,7 +49,7 @@ public async Task RequestHeaders_should_timeout_on_incomplete_headers() using var tcp = new TcpClient(); await tcp.ConnectAsync(IPAddress.Loopback, Port, CancellationToken); var stream = tcp.GetStream(); - var partialBytes = Encoding.ASCII.GetBytes("GET /fast HTTP/1.1\r\nHost: localhost\r\n"); + var partialBytes = "GET /fast HTTP/1.1\r\nHost: localhost\r\n"u8.ToArray(); await stream.WriteAsync(partialBytes, CancellationToken); await Task.Delay(TimeSpan.FromSeconds(3), CancellationToken); @@ -74,7 +74,7 @@ public async Task Server_should_still_respond_after_timeout_disconnects() { await tcp.ConnectAsync(IPAddress.Loopback, Port, CancellationToken); var tcpStream = tcp.GetStream(); - var incompleteBytes = Encoding.ASCII.GetBytes("GET /fast HTTP/1.1\r\nHost: localhost\r\n"); + var incompleteBytes = "GET /fast HTTP/1.1\r\nHost: localhost\r\n"u8.ToArray(); await tcpStream.WriteAsync(incompleteBytes, CancellationToken); await Task.Delay(TimeSpan.FromSeconds(3), CancellationToken); } diff --git a/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs index 9893217f5..da50717bc 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs @@ -1,44 +1,22 @@ using System.Net; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Lifecycle; -public sealed class ServerSmokeSpec : ServerSpecBase +public sealed class ServerSmokeSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/hello", () => Results.Ok("Hello from TurboHTTP Server")); - app.MapPost("/echo", async (HttpContext ctx) => - { - using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); - return Results.Ok(body); - }); - app.MapGet("/connection-info", (HttpContext ctx) => - { - var remoteIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return Results.Ok(remoteIp); - }); - } + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Server_should_respond_to_get_request() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/hello"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/hello"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -50,13 +28,13 @@ public async Task Server_should_respond_to_get_request() [Fact(Timeout = 15000)] public async Task Server_should_echo_post_body() { - var payload = "test payload"; - var request = new HttpRequestMessage(HttpMethod.Post, $"http://127.0.0.1:{Port}/echo") + const string payload = "test payload"; + var request = new HttpRequestMessage(HttpMethod.Post, $"http://127.0.0.1:{server.Port}/echo") { Content = new StringContent(payload) }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); var body = await response.Content.ReadAsStringAsync(CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -67,8 +45,8 @@ public async Task Server_should_echo_post_body() [Fact(Timeout = 15000)] public async Task Server_should_return_404_for_unregistered_route() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/nonexistent"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/nonexistent"), CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -77,8 +55,8 @@ public async Task Server_should_return_404_for_unregistered_route() [Fact(Timeout = 15000)] public async Task Server_should_expose_remote_ip() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/connection-info"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/connection-info"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs index d33560ea2..72e78a419 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs @@ -1,54 +1,21 @@ using System.Net; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Middleware; -public sealed class MiddlewareSpec : ServerSpecBase +public sealed class MiddlewareSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.Use(async (ctx, next) => - { - ctx.Response.Headers.XPoweredBy = "TurboHTTP"; - await next(ctx); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api => - { - api.Use(async (ctx, next) => - { - ctx.Response.Headers["X-Api-Version"] = "2.0"; - await next(ctx); - }); - api.UseRouting(); - api.UseEndpoints(endpoints => - { - endpoints.MapGet("/api/data", () => Results.Ok(new { value = 42 })); - }); - }); - - app.MapGet("/hello", () => Results.Ok("hello")); - app.MapGet("/api/data", () => Results.Ok(new { value = 42 })); - app.MapGet("/other", () => Results.Ok("other")); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Global_middleware_should_set_response_header() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/hello"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/hello"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -59,8 +26,8 @@ public async Task Global_middleware_should_set_response_header() [Fact(Timeout = 15000)] public async Task Mapped_middleware_should_apply_to_matching_path() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/api/data"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/api/data"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -71,8 +38,8 @@ public async Task Mapped_middleware_should_apply_to_matching_path() [Fact(Timeout = 15000)] public async Task Mapped_middleware_should_not_apply_to_other_paths() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/other"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/other"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -82,8 +49,8 @@ public async Task Mapped_middleware_should_not_apply_to_other_paths() [Fact(Timeout = 15000)] public async Task Global_middleware_should_apply_to_all_paths() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/other"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/other"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs index 7d2c61de3..2e381ecf9 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs @@ -1,55 +1,36 @@ using System.Net; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ConnectionInfoSpec : ServerSpecBase +public sealed class ConnectionInfoSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/connection", (HttpContext ctx) => Results.Ok(new - { - remoteIp = ctx.Connection.RemoteIpAddress?.ToString(), - remotePort = ctx.Connection.RemotePort, - localIp = ctx.Connection.LocalIpAddress?.ToString(), - localPort = ctx.Connection.LocalPort - })); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/protocol", (HttpContext ctx) => Results.Ok(new { protocol = ctx.Request.Protocol })); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Connection_should_expose_local_ip_and_port() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/connection"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/connection"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( await response.Content.ReadAsStringAsync(CancellationToken)); Assert.Equal("127.0.0.1", json.RootElement.GetProperty("localIp").GetString()); - Assert.Equal(Port, json.RootElement.GetProperty("localPort").GetInt32()); + Assert.Equal(server.Port, json.RootElement.GetProperty("localPort").GetInt32()); } [Fact(Timeout = 15000)] public async Task Connection_should_expose_remote_ip() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/connection"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/connection"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -62,8 +43,8 @@ public async Task Connection_should_expose_remote_ip() [Fact(Timeout = 15000)] public async Task Request_should_expose_protocol_version() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/protocol"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/protocol"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs index 2bcd80916..ccf818345 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs @@ -1,49 +1,21 @@ using System.Net; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ErrorHandlingSpec : ServerSpecBase +public sealed class ErrorHandlingSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/throw-sync", () => - { - throw new InvalidOperationException("sync boom"); -#pragma warning disable CS0162 - return Results.Ok(); -#pragma warning restore CS0162 - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/throw-async", async () => - { - await Task.Yield(); - throw new InvalidOperationException("async boom"); -#pragma warning disable CS0162 - return Results.Ok(); -#pragma warning restore CS0162 - }); - - app.MapGet("/ok", () => Results.Ok("fine")); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Sync_handler_exception_should_return_500() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/throw-sync"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/throw-sync"), CancellationToken); Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); @@ -52,8 +24,8 @@ public async Task Sync_handler_exception_should_return_500() [Fact(Timeout = 15000)] public async Task Async_handler_exception_should_return_500() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/throw-async"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/throw-async"), CancellationToken); Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); @@ -62,12 +34,12 @@ public async Task Async_handler_exception_should_return_500() [Fact(Timeout = 15000)] public async Task Server_should_recover_after_handler_exception() { - await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/throw-sync"), + await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/throw-sync"), CancellationToken); - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/ok"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/ok"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs index e1989b8d2..c41c532c3 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs @@ -1,51 +1,22 @@ using System.Net; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ParameterBindingSpec : ServerSpecBase +public sealed class ParameterBindingSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } - - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/users/{id:int}", (int id) => - Results.Ok(new { id })); + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - app.MapGet("/search", (string q) => - Results.Ok(new { query = q })); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/paged", (string q, int page) => - Results.Ok(new { query = q, page })); - - app.MapGet("/with-header", - ([FromHeader(Name = "X-Tenant")] string tenant) => - Results.Ok(new { tenant })); - - app.MapGet("/optional", (string? name) => - Results.Ok(new { name = name ?? "default" })); - - app.MapGet("/items/{category}/{id}", (string category, int id) => - Results.Ok(new { category, id })); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Route_param_should_bind_int_from_path() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/users/42"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/users/42"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -56,8 +27,8 @@ public async Task Route_param_should_bind_int_from_path() [Fact(Timeout = 15000)] public async Task Query_string_should_bind_string_param() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/search?q=turbohttp"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/search?q=turbohttp"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -68,8 +39,8 @@ public async Task Query_string_should_bind_string_param() [Fact(Timeout = 15000)] public async Task Multiple_query_params_should_bind() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/paged?q=test&page=3"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/paged?q=test&page=3"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -82,10 +53,10 @@ public async Task Multiple_query_params_should_bind() public async Task Header_should_bind_from_request_header() { var request = new HttpRequestMessage(HttpMethod.Get, - new Uri($"http://127.0.0.1:{Port}/with-header")); + new Uri($"http://127.0.0.1:{server.Port}/with-header")); request.Headers.Add("X-Tenant", "acme-corp"); - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -96,8 +67,8 @@ public async Task Header_should_bind_from_request_header() [Fact(Timeout = 15000)] public async Task Optional_param_should_use_default_when_missing() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/optional"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/optional"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -108,8 +79,8 @@ public async Task Optional_param_should_use_default_when_missing() [Fact(Timeout = 15000)] public async Task Optional_param_should_use_provided_value() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/optional?name=jan"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/optional?name=jan"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -120,8 +91,8 @@ public async Task Optional_param_should_use_provided_value() [Fact(Timeout = 15000)] public async Task Multiple_route_params_should_bind() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/items/electronics/99"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/items/electronics/99"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs index e6a42c1e0..f481b6c22 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs @@ -1,55 +1,23 @@ using System.Net; using System.Text; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class RequestBodySpec : ServerSpecBase +public sealed class RequestBodySpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapPost("/echo-body", async (HttpContext ctx) => - { - using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(); - return Results.Ok(new { body }); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapPost("/echo-json", async (HttpContext ctx) => - { - using var reader = new StreamReader(ctx.Request.Body); - var raw = await reader.ReadToEndAsync(); - var parsed = JsonDocument.Parse(raw); - return Results.Ok(parsed.RootElement); - }); - - app.MapPost("/form", async (HttpContext ctx) => - { - var form = await ctx.Request.ReadFormAsync(); - var name = form["name"].ToString(); - var age = form["age"].ToString(); - return Results.Ok(new { name, age }); - }); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Post_should_receive_text_body() { - var response = await Client.PostAsync( - new Uri($"http://127.0.0.1:{Port}/echo-body"), + var response = await _client.PostAsync( + new Uri($"http://127.0.0.1:{server.Port}/echo-body"), new StringContent("hello server", Encoding.UTF8, "text/plain"), CancellationToken); @@ -67,8 +35,8 @@ public async Task Post_should_receive_json_body() Encoding.UTF8, "application/json"); - var response = await Client.PostAsync( - new Uri($"http://127.0.0.1:{Port}/echo-json"), + var response = await _client.PostAsync( + new Uri($"http://127.0.0.1:{server.Port}/echo-json"), jsonContent, CancellationToken); @@ -89,8 +57,8 @@ public async Task Post_should_receive_form_encoded_body() }; var content = new FormUrlEncodedContent(formData); - var response = await Client.PostAsync( - new Uri($"http://127.0.0.1:{Port}/form"), + var response = await _client.PostAsync( + new Uri($"http://127.0.0.1:{server.Port}/form"), content, CancellationToken); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs index 90ad094e5..87c7fb2e8 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs @@ -1,50 +1,21 @@ using System.Net; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ResponseHeadersSpec : ServerSpecBase +public sealed class ResponseHeadersSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/custom-header", (HttpContext ctx) => - { - ctx.Response.Headers["X-Request-Id"] = "abc-123"; - return Results.Ok("ok"); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/multi-header", (HttpContext ctx) => - { - ctx.Response.Headers.Append("X-Tag", "alpha"); - ctx.Response.Headers.Append("X-Tag", "beta"); - return Results.Ok("ok"); - }); - - app.MapGet("/cache-headers", (HttpContext ctx) => - { - ctx.Response.Headers.CacheControl = "no-cache, no-store"; - ctx.Response.Headers.ETag = "\"v1\""; - return Results.Ok("cached"); - }); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Custom_response_header_should_arrive_at_client() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/custom-header"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/custom-header"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -55,8 +26,8 @@ public async Task Custom_response_header_should_arrive_at_client() [Fact(Timeout = 15000)] public async Task Multiple_values_for_same_header_should_arrive() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/multi-header"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/multi-header"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -69,8 +40,8 @@ public async Task Multiple_values_for_same_header_should_arrive() [Fact(Timeout = 15000)] public async Task Standard_cache_headers_should_arrive() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/cache-headers"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/cache-headers"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs index c22a28175..87a1bc52c 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs @@ -2,58 +2,23 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class RoutingEdgeCasesSpec : ServerSpecBase +public sealed class RoutingEdgeCasesSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/multi", () => - Results.Ok(new { method = "GET" })); - app.MapPost("/multi", () => - Results.Ok(new { method = "POST" })); - app.MapPut("/multi", () => - Results.Ok(new { method = "PUT" })); - - app.MapPost("/upload", async (HttpContext ctx) => - { - var form = await ctx.Request.ReadFormAsync(); - var file = form.Files.GetFile("document"); - if (file is null) - { - return Results.BadRequest("No file"); - } - - using var reader = new StreamReader(file.OpenReadStream()); - var content = await reader.ReadToEndAsync(); - return Results.Ok(new - { - fileName = file.FileName, - size = file.Length, - content - }); - }); - } + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Multi_method_route_should_handle_GET() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/multi"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/multi"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -65,12 +30,12 @@ public async Task Multi_method_route_should_handle_GET() public async Task Multi_method_route_should_handle_POST() { var request = new HttpRequestMessage(HttpMethod.Post, - new Uri($"http://127.0.0.1:{Port}/multi")) + new Uri($"http://127.0.0.1:{server.Port}/multi")) { Content = new StringContent("") }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -82,12 +47,12 @@ public async Task Multi_method_route_should_handle_POST() public async Task Multi_method_route_should_handle_PUT() { var request = new HttpRequestMessage(HttpMethod.Put, - new Uri($"http://127.0.0.1:{Port}/multi")) + new Uri($"http://127.0.0.1:{server.Port}/multi")) { Content = new StringContent("") }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -99,9 +64,9 @@ public async Task Multi_method_route_should_handle_PUT() public async Task Multi_method_route_should_return_404_for_unregistered_method() { var request = new HttpRequestMessage(HttpMethod.Delete, - new Uri($"http://127.0.0.1:{Port}/multi")); + new Uri($"http://127.0.0.1:{server.Port}/multi")); - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } @@ -118,12 +83,12 @@ public async Task Upload_should_receive_multipart_file() multipart.Add(fileStream, "document", "test.txt"); var request = new HttpRequestMessage(HttpMethod.Post, - new Uri($"http://127.0.0.1:{Port}/upload")) + new Uri($"http://127.0.0.1:{server.Port}/upload")) { Content = multipart }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(CancellationToken)); diff --git a/src/TurboHTTP.IntegrationTests.Server/Server/KeepAliveResponseSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Server/KeepAliveResponseSpec.cs new file mode 100644 index 000000000..8b4ab3cc3 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Server/KeepAliveResponseSpec.cs @@ -0,0 +1,50 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +public sealed class KeepAliveResponseSpec +{ + // Regression for the memory-endurance stall. Http11ServerStateMachine tracked the + // pipeline-depth limit with a cumulative `_requestsPipelined` counter that was never + // decremented, so a keep-alive connection silently dropped request number + // (MaxPipelinedRequests + 1) — no response, no close — and the client timed out. + [Fact(Timeout = 30000)] + public async Task Server_should_keep_serving_a_keepalive_connection_past_the_pipeline_depth() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.Host.UseTurboHttp(o => + o.Listen(IPAddress.Loopback, 0, lo => lo.Protocols = HttpProtocols.Http1)); + + await using var app = builder.Build(); + app.MapGet("/data", () => Results.Text("hello-world")); + + await app.StartAsync(TestContext.Current.CancellationToken); + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.First(); + + using var handler = new SocketsHttpHandler { MaxConnectionsPerServer = 1 }; + using var client = new HttpClient(handler) + { + BaseAddress = new Uri(address), + // A stalled response trips this long before the 30 s server/client defaults. + Timeout = TimeSpan.FromSeconds(3), + }; + + // Far beyond the default MaxPipelinedRequests (16) on a single reused connection. + for (var i = 0; i < 40; i++) + { + using var response = await client.GetAsync("/data", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + await app.StopAsync(TestContext.Current.CancellationToken); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Server/RequestBodySpec.cs b/src/TurboHTTP.IntegrationTests.Server/Server/RequestBodySpec.cs new file mode 100644 index 000000000..7cdfc7d20 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Server/RequestBodySpec.cs @@ -0,0 +1,53 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +public sealed class RequestBodySpec +{ + [Fact(Timeout = 15000)] + public async Task Server_should_accept_request_body_larger_than_legacy_32kb_cap() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.Host.UseTurboHttp(o => + o.Listen(IPAddress.Loopback, 0, lo => lo.Protocols = HttpProtocols.Http1)); + + await using var app = builder.Build(); + app.MapPost("/echo", async ctx => + { + long count = 0; + var buf = new byte[64 * 1024]; + int read; + while ((read = await ctx.Request.Body.ReadAsync(buf)) > 0) + { + count += read; + } + + await ctx.Response.WriteAsync(count.ToString()); + }); + + await app.StartAsync(TestContext.Current.CancellationToken); + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.First(); + + using var client = new HttpClient(); + client.BaseAddress = new Uri(address); + var payload = new byte[256 * 1024]; + + var response = + await client.PostAsync("/echo", new ByteArrayContent(payload), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal((256 * 1024).ToString(), + await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + await app.StopAsync(TestContext.Current.CancellationToken); + } +} \ No newline at end of file diff --git a/src/TurboHTTP.IntegrationTests.Server/Shared/MultiProtocolTlsServerSpecBase.cs b/src/TurboHTTP.IntegrationTests.Server/Shared/MultiProtocolTlsServerSpecBase.cs new file mode 100644 index 000000000..7b917063a --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Shared/MultiProtocolTlsServerSpecBase.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server.Shared; + +/// +/// Base for tests that drive TurboServer over TLS with one or more negotiated protocols, +/// using a neutral .NET as the reference client. Subclasses choose +/// the server's advertised protocols via ; tests pick the client's +/// requested version per call via . +/// +public abstract class MultiProtocolTlsServerSpecBase : ServerSpecBase +{ + protected abstract HttpProtocols ServerProtocols { get; } + + protected virtual Version DefaultClientVersion => HttpVersion.Version20; + + protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) + { + var certificate = CreateSelfSignedCertificate("localhost"); + builder.Host.UseTurboHttp(options => ConfigureListener(options, port, certificate)); + } + + /// + /// Binds the server listener. Default is TCP + TLS advertising . + /// H3 subclasses override this to bind a QUIC listener. + /// + protected virtual void ConfigureListener(TurboServerOptions options, ushort port, X509Certificate2 certificate) + { + options.ListenLocalhost(port, listen => + { + listen.UseHttps(certificate); + listen.Protocols = ServerProtocols; + }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + // Echoes the protocol the server actually negotiated for this request ("HTTP/1.1", "HTTP/2", "HTTP/3"). + app.MapGet("/protocol", (HttpContext ctx) => Results.Ok(ctx.Request.Protocol)); + + app.MapPost("/echo", async (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(CancellationToken); + return Results.Ok(body); + }); + } + + protected override HttpClient CreateHttpClient() => CreateVersionedTlsClient(DefaultClientVersion); + + /// + /// Creates a TLS client (accepting the self-signed cert) that requests + /// with RequestVersionOrLower, so the negotiated protocol reflects ALPN selection. + /// + protected HttpClient CreateVersionedTlsClient(Version version) + { + var client = CreateTlsClient(); + client.DefaultRequestVersion = version; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + return client; + } + + /// + /// Creates a TLS client that requests with RequestVersionExact, + /// so ALPN offers only that protocol — the connection is that version or the request fails. + /// + protected HttpClient CreateExactVersionTlsClient(Version version) + { + var client = CreateTlsClient(); + client.DefaultRequestVersion = version; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + return client; + } + + protected Uri Url(string path) => new($"https://127.0.0.1:{Port}{path}"); + + /// + /// Builds a request pinned to with RequestVersionExact. + /// Caller-constructed instances do NOT inherit the client's + /// DefaultRequestVersion (only the convenience methods like GetAsync do), so a manual + /// SendAsync request must carry the version itself to exercise the intended protocol. + /// + protected HttpRequestMessage NewRequest(HttpMethod method, string path) => + new(method, Url(path)) + { + Version = DefaultClientVersion, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Shared/ServerSpecBase.cs b/src/TurboHTTP.IntegrationTests.Server/Shared/ServerSpecBase.cs index 844ff5784..a5e40d15c 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Shared/ServerSpecBase.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Shared/ServerSpecBase.cs @@ -1,8 +1,10 @@ using System.Net; -using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace TurboHTTP.IntegrationTests.Server.Shared; @@ -26,15 +28,17 @@ public abstract class ServerSpecBase : IAsyncLifetime protected virtual HttpClient? CreateHttpClient() => new(); - public async ValueTask InitializeAsync() + public virtual async ValueTask InitializeAsync() { - Port = GetFreePort(); + // Bind port 0 and read the real port back after start — probing for a free + // port and rebinding it races with parallel tests (and parallel test modules). var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); - ConfigureServer(builder, Port); + ConfigureServer(builder, 0); _app = builder.Build(); ConfigureEndpoints(_app); await _app.StartAsync(); + Port = ResolveBoundPort(_app); _client = CreateHttpClient(); } @@ -92,12 +96,11 @@ protected static X509Certificate2 CreateSelfSignedCertificate(string cn) X509KeyStorageFlags.Exportable); } - private static ushort GetFreePort() + internal static ushort ResolveBoundPort(WebApplication app) { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return (ushort)port; + var addresses = app.Services.GetRequiredService() + .Features.Get()! + .Addresses; + return (ushort)new Uri(addresses.First()).Port; } } diff --git a/src/TurboHTTP.IntegrationTests.Server/Shared/ServerStressCollection.cs b/src/TurboHTTP.IntegrationTests.Server/Shared/ServerStressCollection.cs new file mode 100644 index 000000000..39773a5da --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Shared/ServerStressCollection.cs @@ -0,0 +1,11 @@ +using TurboHTTP.IntegrationTests.Server.Shared; + +[assembly: AssemblyFixture(typeof(TurboServerFixture))] + +namespace TurboHTTP.IntegrationTests.Server.Shared; + +[CollectionDefinition("ServerStress", DisableParallelization = true)] +public sealed class ServerStressCollection; + +[CollectionDefinition("Infrastructure", DisableParallelization = true)] +public sealed class InfrastructureCollection; diff --git a/src/TurboHTTP.IntegrationTests.Server/Shared/TurboServerFixture.cs b/src/TurboHTTP.IntegrationTests.Server/Shared/TurboServerFixture.cs new file mode 100644 index 000000000..dbb0d7a28 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Shared/TurboServerFixture.cs @@ -0,0 +1,251 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Servus.Akka.Transport; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server.Shared; + +public sealed class TurboServerFixture : IAsyncLifetime +{ + private WebApplication? _app; + + public ushort Port { get; private set; } + + public static HttpClient CreateClient() => new(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.Zero + }); + + public async ValueTask InitializeAsync() + { + // Bind port 0 and read the real port back after start — probing for a free + // port and rebinding it races with parallel tests (and parallel test modules). + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = 0 }); + }); + + _app = builder.Build(); + RegisterEndpoints(_app); + await _app.StartAsync(); + Port = ServerSpecBase.ResolveBoundPort(_app); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private static void RegisterEndpoints(WebApplication app) + { + app.Use(async (ctx, next) => + { + ctx.Response.Headers.XPoweredBy = "TurboHTTP"; + await next(ctx); + }); + + app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api => + { + api.Use(async (ctx, next) => + { + ctx.Response.Headers["X-Api-Version"] = "2.0"; + await next(ctx); + }); + api.UseRouting(); + api.UseEndpoints(endpoints => { endpoints.MapGet("/api/data", () => Results.Ok(new { value = 42 })); }); + }); + + // Basic + app.MapGet("/ping", () => Results.Content("pong", "text/plain")); + app.MapGet("/hello", () => Results.Ok("Hello from TurboHTTP Server")); + app.MapGet("/other", () => Results.Ok("other")); + app.MapGet("/ok", () => Results.Ok("fine")); + app.MapGet("/echo", () => Results.Ok("ok")); + app.MapGet("/text", () => Results.Ok("hello world")); + app.MapGet("/api/data", () => Results.Ok(new { value = 42 })); + + // Echo / body + app.MapPost("/echo", async (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + return Results.Ok(body); + }); + app.MapPost("/echo-body", async (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + return Results.Ok(new { body }); + }); + app.MapPost("/echo-json", async (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var raw = await reader.ReadToEndAsync(); + var parsed = JsonDocument.Parse(raw); + return Results.Ok(parsed.RootElement); + }); + app.MapPost("/form", async (HttpContext ctx) => + { + var form = await ctx.Request.ReadFormAsync(); + var name = form["name"].ToString(); + var age = form["age"].ToString(); + return Results.Ok(new { name, age }); + }); + + // Connection info + app.MapGet("/connection-info", (HttpContext ctx) => + { + var remoteIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return Results.Ok(remoteIp); + }); + app.MapGet("/connection", (HttpContext ctx) => Results.Ok(new + { + remoteIp = ctx.Connection.RemoteIpAddress?.ToString(), + remotePort = ctx.Connection.RemotePort, + localIp = ctx.Connection.LocalIpAddress?.ToString(), + localPort = ctx.Connection.LocalPort + })); + app.MapGet("/protocol", (HttpContext ctx) => Results.Ok(new { protocol = ctx.Request.Protocol })); + + // Error handling + app.MapGet("/throw-sync", () => + { + throw new InvalidOperationException("sync boom"); +#pragma warning disable CS0162 + return Results.Ok(); +#pragma warning restore CS0162 + }); + app.MapGet("/throw-async", async () => + { + await Task.Yield(); + throw new InvalidOperationException("async boom"); +#pragma warning disable CS0162 + return Results.Ok(); +#pragma warning restore CS0162 + }); + + // Parameter binding + app.MapGet("/users/{id:int}", (int id) => Results.Ok(new { id })); + app.MapGet("/search", (string q) => Results.Ok(new { query = q })); + app.MapGet("/paged", (string q, int page) => Results.Ok(new { query = q, page })); + app.MapGet("/with-header", ([FromHeader(Name = "X-Tenant")] string tenant) => Results.Ok(new { tenant })); + app.MapGet("/optional", (string? name) => Results.Ok(new { name = name ?? "default" })); + app.MapGet("/items/{category}/{id:int}", (string category, int id) => Results.Ok(new { category, id })); + + // Response headers + app.MapGet("/custom-header", (HttpContext ctx) => + { + ctx.Response.Headers["X-Request-Id"] = "abc-123"; + return Results.Ok("ok"); + }); + app.MapGet("/multi-header", (HttpContext ctx) => + { + ctx.Response.Headers.Append("X-Tag", "alpha"); + ctx.Response.Headers.Append("X-Tag", "beta"); + return Results.Ok("ok"); + }); + app.MapGet("/cache-headers", (HttpContext ctx) => + { + ctx.Response.Headers.CacheControl = "no-cache, no-store"; + ctx.Response.Headers.ETag = "\"v1\""; + return Results.Ok("cached"); + }); + + // Multi-method routing + app.MapGet("/multi", () => Results.Ok(new { method = "GET" })); + app.MapPost("/multi", () => Results.Ok(new { method = "POST" })); + app.MapPut("/multi", () => Results.Ok(new { method = "PUT" })); + app.MapPost("/upload", async (HttpContext ctx) => + { + var form = await ctx.Request.ReadFormAsync(); + var file = form.Files.GetFile("document"); + if (file is null) + { + return Results.BadRequest("No file"); + } + + using var reader = new StreamReader(file.OpenReadStream()); + var content = await reader.ReadToEndAsync(); + return Results.Ok(new { fileName = file.FileName, size = file.Length, content }); + }); + + // Streaming + app.MapGet("/stream-bytes", () => + { + var chunks = new[] { new byte[] { 1, 2, 3 }, new byte[] { 4, 5, 6 }, new byte[] { 7, 8, 9 } }; + return Results.Stream(async stream => + { + foreach (var chunk in chunks) + { + await stream.WriteAsync(chunk); + } + }, "application/octet-stream"); + }); + app.MapGet("/stream-text", () => + { + var lines = new[] { "line1\n", "line2\n", "line3\n" }; + return Results.Stream(async stream => + { + foreach (var line in lines) + { + await stream.WriteAsync(Encoding.UTF8.GetBytes(line)); + } + }, "text/plain"); + }); + app.MapGet("/stream-large", () => + { + return Results.Stream(async stream => + { + var chunk = new byte[1024]; + Array.Fill(chunk, (byte)0xAB); + for (var i = 0; i < 100; i++) + { + await stream.WriteAsync(chunk); + } + }, "application/octet-stream"); + }); + app.MapGet("/stream-no-cl", () => + { + var chunks = new[] { "chunk1", "chunk2", "chunk3" }; + return Results.Stream(async stream => + { + foreach (var chunk in chunks) + { + await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); + } + }, "text/plain"); + }); + app.MapGet("/with-cl", ctx => + { + var body = "exact-length-body"u8.ToArray(); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "text/plain"; + ctx.Response.ContentLength = body.Length; + return ctx.Response.Body.WriteAsync(body).AsTask(); + }); + app.MapGet("/no-content", Results.NoContent); + app.MapGet("/not-modified", () => Results.StatusCode(304)); + + // SSE + app.MapGet("/events", async ctx => + { + ctx.Response.ContentType = "text/event-stream"; + var events = new[] { "event1", "event2" }; + foreach (var evt in events) + { + var data = Encoding.UTF8.GetBytes($"data: {evt}\n\n"); + await ctx.Response.Body.WriteAsync(data); + } + }); + } +} \ No newline at end of file diff --git a/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs b/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs index f89118a10..88024877b 100644 --- a/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs @@ -1,44 +1,21 @@ using System.Net; -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server; -public sealed class SseServerSpec : ServerSpecBase +public sealed class SseServerSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/echo", () => Results.Ok("ok")); - app.MapGet("/text", () => Results.Ok("hello world")); - app.MapGet("/events", async (HttpContext ctx) => - { - ctx.Response.ContentType = "text/event-stream"; - var events = new[] { "event1", "event2" }; - foreach (var evt in events) - { - var data = Encoding.UTF8.GetBytes($"data: {evt}\n\n"); - await ctx.Response.Body.WriteAsync(data); - } - }); - } + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Server_should_respond_to_basic_request() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/echo"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/echo"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -47,8 +24,8 @@ public async Task Server_should_respond_to_basic_request() [Fact(Timeout = 15000)] public async Task Server_should_respond_to_text_request() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/text"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/text"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -59,8 +36,8 @@ public async Task Server_should_respond_to_text_request() [Fact(Timeout = 15000)] public async Task Server_should_return_correct_content_type() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/text"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/text"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -71,8 +48,8 @@ public async Task Server_should_return_correct_content_type() [Fact(Timeout = 15000)] public async Task Server_should_return_404_for_unregistered_route() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/nonexistent"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/nonexistent"), CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -81,8 +58,8 @@ public async Task Server_should_return_404_for_unregistered_route() [Fact(Timeout = 15000)] public async Task Server_should_stream_sse_events() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/events"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/events"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs index b310bc822..c3b8b62ae 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs @@ -1,73 +1,21 @@ using System.Net; -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Streaming; -public sealed class RawStreamingSpec : ServerSpecBase +public sealed class RawStreamingSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/stream-bytes", () => - { - var chunks = new[] - { - new byte[] { 1, 2, 3 }, - new byte[] { 4, 5, 6 }, - new byte[] { 7, 8, 9 } - }; - return Results.Stream(async stream => - { - foreach (var chunk in chunks) - { - await stream.WriteAsync(chunk); - } - }, "application/octet-stream"); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/stream-text", () => - { - var lines = new[] { "line1\n", "line2\n", "line3\n" }; - return Results.Stream(async stream => - { - foreach (var line in lines) - { - await stream.WriteAsync(Encoding.UTF8.GetBytes(line)); - } - }, "text/plain"); - }); - - app.MapGet("/stream-large", () => - { - return Results.Stream(async stream => - { - var chunk = new byte[1024]; - Array.Fill(chunk, (byte)0xAB); - for (var i = 0; i < 100; i++) - { - await stream.WriteAsync(chunk); - } - }, "application/octet-stream"); - }); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Stream_should_return_all_bytes() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-bytes"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-bytes"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -78,8 +26,8 @@ public async Task Stream_should_return_all_bytes() [Fact(Timeout = 15000)] public async Task Stream_should_set_custom_content_type() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-text"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-text"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -91,8 +39,8 @@ public async Task Stream_should_set_custom_content_type() [Fact(Timeout = 30000)] public async Task Stream_should_handle_large_payload() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-large"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-large"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs b/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs index e86b73bf3..d634b4a3e 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs @@ -1,56 +1,21 @@ using System.Net; -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Streaming; -public sealed class ResponseBodySpec : ServerSpecBase +public sealed class ResponseBodySpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } - - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/stream-no-cl", () => - { - var chunks = new[] { "chunk1", "chunk2", "chunk3" }; - return Results.Stream(async stream => - { - foreach (var chunk in chunks) - { - await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); - } - }, "text/plain"); - }); - - app.MapGet("/with-cl", (HttpContext ctx) => - { - var body = Encoding.UTF8.GetBytes("exact-length-body"); - ctx.Response.StatusCode = 200; - ctx.Response.ContentType = "text/plain"; - ctx.Response.ContentLength = body.Length; - return ctx.Response.Body.WriteAsync(body).AsTask(); - }); + private readonly HttpClient _client = TurboServerFixture.CreateClient(); - app.MapGet("/no-content", () => Results.NoContent()); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/not-modified", () => Results.StatusCode(304)); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Streaming_response_without_content_length_should_deliver_all_chunks() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-no-cl"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-no-cl"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -61,8 +26,8 @@ public async Task Streaming_response_without_content_length_should_deliver_all_c [Fact(Timeout = 15000)] public async Task Streaming_response_without_content_length_should_set_content_type() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-no-cl"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-no-cl"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -73,8 +38,8 @@ public async Task Streaming_response_without_content_length_should_set_content_t [Fact(Timeout = 15000)] public async Task Response_with_content_length_should_return_exact_body() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/with-cl"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/with-cl"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -86,8 +51,8 @@ public async Task Response_with_content_length_should_return_exact_body() [Fact(Timeout = 15000)] public async Task NoContent_204_should_have_empty_body() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/no-content"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/no-content"), CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); @@ -98,8 +63,8 @@ public async Task NoContent_204_should_have_empty_body() [Fact(Timeout = 15000)] public async Task NotModified_304_should_have_empty_body() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/not-modified"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/not-modified"), CancellationToken); Assert.Equal((HttpStatusCode)304, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json b/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json index 0967ef424..73179ea81 100644 --- a/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json +++ b/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json @@ -1 +1,6 @@ -{} +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": true, + "parallelizeAssembly": false, + "maxParallelThreads": 4 +} diff --git a/src/TurboHTTP.StressBenchmarks/Reporting/StressReport.cs b/src/TurboHTTP.StressBenchmarks/Reporting/StressReport.cs index 2f056be0a..88fc0a629 100644 --- a/src/TurboHTTP.StressBenchmarks/Reporting/StressReport.cs +++ b/src/TurboHTTP.StressBenchmarks/Reporting/StressReport.cs @@ -1,5 +1,3 @@ -using System.Text; - namespace TurboHTTP.StressBenchmarks.Reporting; public static class StressReport diff --git a/src/TurboHTTP.Tests.Shared/AcceptanceTestBase.cs b/src/TurboHTTP.Tests.Shared/AcceptanceTestBase.cs index 351d963a3..7e8358036 100644 --- a/src/TurboHTTP.Tests.Shared/AcceptanceTestBase.cs +++ b/src/TurboHTTP.Tests.Shared/AcceptanceTestBase.cs @@ -1,8 +1,7 @@ -using TurboHTTP.Client; using System.Text; -using Akka; using Akka.Streams.Dsl; using Servus.Akka.Transport; +using TurboHTTP.Client; using TurboHTTP.Streams; using Xunit; @@ -10,50 +9,34 @@ namespace TurboHTTP.Tests.Shared; public abstract class AcceptanceTestBase : EngineTestBase { - internal static IClientProtocolEngine CreateHttp10Engine(Action? configure = null) + internal static IClientProtocolEngine CreateHttp10Engine(Action? configure = null) { var clientOptions = new TurboClientOptions(); configure?.Invoke(clientOptions.Http1); return new Http10ClientEngine(clientOptions); } - internal static IClientProtocolEngine CreateHttp11Engine(Action? configure = null) + internal static IClientProtocolEngine CreateHttp11Engine(Action? configure = null) { var clientOptions = new TurboClientOptions(); configure?.Invoke(clientOptions.Http1); return new Http11ClientEngine(clientOptions); } - internal static IClientProtocolEngine CreateHttp20Engine(Action? configure = null) + internal static IClientProtocolEngine CreateHttp20Engine(Action? configure = null) { var clientOptions = new TurboClientOptions(); configure?.Invoke(clientOptions.Http2); return new Http20ClientEngine(clientOptions); } - internal static IClientProtocolEngine CreateHttp30Engine(Action? configure = null) + internal static IClientProtocolEngine CreateHttp30Engine(Action? configure = null) { var clientOptions = new TurboClientOptions(); configure?.Invoke(clientOptions.Http3); return new Http30ClientEngine(clientOptions); } - internal async Task SendScriptedAsync( - IClientProtocolEngine engine, - HttpRequestMessage request, - Func responseFactory) - { - var stage = CreateScriptedConnection(responseFactory); - var flow = engine.CreateFlow().Join(stage.AsFlow()); - - var tcs = new TaskCompletionSource(); - _ = Source.Single(request) - .Via(flow) - .RunWith(Sink.ForEach(res => tcs.TrySetResult(res)), Materializer); - - return await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - } - internal async Task<(HttpResponseMessage Response, string RawRequest)> SendScriptedWithCaptureAsync( IClientProtocolEngine engine, HttpRequestMessage request, @@ -80,21 +63,4 @@ internal async Task SendScriptedAsync( return (response, rawBuilder.ToString()); } - - protected async Task SendWithFakeAsync( - BidiFlow featurePipeline, - ResponseMap map, - HttpRequestMessage request) - { - var fake = ResponseMapFake.Create(map); - var flow = featurePipeline.Atop(fake) - .Join(Flow.FromFunction(_ => new HttpResponseMessage())); - - var tcs = new TaskCompletionSource(); - _ = Source.Single(request) - .Via(flow) - .RunWith(Sink.ForEach(res => tcs.TrySetResult(res)), Materializer); - - return await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - } -} +} \ No newline at end of file diff --git a/src/TurboHTTP.Tests.Shared/ActorSystemFixture.cs b/src/TurboHTTP.Tests.Shared/ActorSystemFixture.cs index d9e89b9f4..0696a26bd 100644 --- a/src/TurboHTTP.Tests.Shared/ActorSystemFixture.cs +++ b/src/TurboHTTP.Tests.Shared/ActorSystemFixture.cs @@ -3,7 +3,7 @@ using Akka.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; using TurboHTTP.Diagnostics; using Xunit; @@ -24,7 +24,7 @@ public ValueTask InitializeAsync() }); var traceListener = new LoggerTraceListener(loggerFactory); - Servus.Core.Servus.Tracing.Configure(traceListener, TraceLevel.Info); + Servus.Senf.Tracing.Configure(traceListener, TraceLevel.Info); var services = new ServiceCollection(); var diSetup = DependencyResolverSetup.Create(services.BuildServiceProvider()); diff --git a/src/TurboHTTP.Tests.Shared/ClientAcceptanceHelper.cs b/src/TurboHTTP.Tests.Shared/ClientAcceptanceHelper.cs index 393d04060..339e142a4 100644 --- a/src/TurboHTTP.Tests.Shared/ClientAcceptanceHelper.cs +++ b/src/TurboHTTP.Tests.Shared/ClientAcceptanceHelper.cs @@ -1,9 +1,9 @@ -using TurboHTTP.Client; using Akka.Actor; using Akka.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using TurboHTTP.Client; using TurboHTTP.Streams; namespace TurboHTTP.Tests.Shared; diff --git a/src/TurboHTTP.Tests.Shared/ClientHelper.cs b/src/TurboHTTP.Tests.Shared/ClientHelper.cs index a8ee5f051..89f3a4fb7 100644 --- a/src/TurboHTTP.Tests.Shared/ClientHelper.cs +++ b/src/TurboHTTP.Tests.Shared/ClientHelper.cs @@ -1,4 +1,3 @@ -using TurboHTTP.Client; using Akka.Actor; using Akka.Configuration; using Akka.DependencyInjection; @@ -7,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using TurboHTTP.Client; namespace TurboHTTP.Tests.Shared; diff --git a/src/TurboHTTP.Tests.Shared/EngineTestBase.cs b/src/TurboHTTP.Tests.Shared/EngineTestBase.cs index 5ad206509..43f581664 100644 --- a/src/TurboHTTP.Tests.Shared/EngineTestBase.cs +++ b/src/TurboHTTP.Tests.Shared/EngineTestBase.cs @@ -1,12 +1,11 @@ -using System.Diagnostics; using System.Text; using Akka; using Akka.Streams.Dsl; using Servus.Akka.TestKit; using Servus.Akka.Transport; -using Xunit; using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http3; +using Xunit; using FrameDecoder = TurboHTTP.Protocol.Syntax.Http2.FrameDecoder; namespace TurboHTTP.Tests.Shared; @@ -20,7 +19,7 @@ internal static TestConnectionStage CreateFakeConnection(Func responseFa .Build(); stage.PushResponse(outbound => outbound is TransportData - ? new TransportData(responseFactory()) + ? TransportData.Rent(responseFactory()) : null); return stage; @@ -45,7 +44,7 @@ internal static TestConnectionStage CreateScriptedConnection(Func((_, ctx) => { tunnelEstablished = true; - ctx.Push(new TransportData(connectEstablishedBytes)); + ctx.Push(TransportData.Rent(connectEstablishedBytes)); }) .OnOutbound((data, ctx) => { @@ -161,7 +160,7 @@ internal static TestConnectionStage CreateProxyConnection(Func((msg, ctx) => + .OnOutbound((_, ctx) => { transportDataCount++; @@ -193,7 +192,7 @@ void PushNextFrame(IStageContext ctx) { if (frameIndex < serverFrames.Length) { - ctx.Push(new TransportData(serverFrames[frameIndex++])); + ctx.Push(TransportData.Rent(serverFrames[frameIndex++])); } } } @@ -409,11 +408,9 @@ private static List DrainOutboundBytes(TestConnectionStage stage, bool str var preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8; var bytes = new List(); var prefaceStripped = false; - var messageCount = 0; while (stage.TryGetOutbound(out var outbound)) { - messageCount++; if (outbound is not TransportData { Buffer: var buf }) { continue; @@ -438,8 +435,6 @@ private static List DrainOutboundBytes(TestConnectionStage stage, bool str bytes.AddRange(span.ToArray()); } - Debug.WriteLine($"DrainOutboundBytes: {messageCount} outbound messages, {bytes.Count} total bytes"); - return bytes; } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests.Shared/FakeClientOps.cs b/src/TurboHTTP.Tests.Shared/FakeClientOps.cs new file mode 100644 index 000000000..8da0525dc --- /dev/null +++ b/src/TurboHTTP.Tests.Shared/FakeClientOps.cs @@ -0,0 +1,24 @@ +using Akka.Actor; +using Servus.Akka.Transport; +using TurboHTTP.Streams.Stages.Client; + +namespace TurboHTTP.Tests.Shared; + +internal sealed class FakeClientOps : IClientStageOperations +{ + public List Responses { get; } = []; + public List Outbound { get; } = []; + + public void OnResponse(HttpResponseMessage response) => Responses.Add(response); + public void OnOutbound(ITransportOutbound item) => Outbound.Add(item); + + public void OnScheduleTimer(string name, TimeSpan duration) + { + } + + public void OnCancelTimer(string name) + { + } + + public IActorRef StageActor { get; init; } = ActorRefs.Nobody; +} \ No newline at end of file diff --git a/src/TurboHTTP.Tests.Shared/FakeOps.cs b/src/TurboHTTP.Tests.Shared/FakeOps.cs deleted file mode 100644 index 14ed6b7e6..000000000 --- a/src/TurboHTTP.Tests.Shared/FakeOps.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Akka.Actor; -using Akka.Event; -using Servus.Akka.Transport; -using TurboHTTP.Streams.Stages.Client; - -namespace TurboHTTP.Tests.Shared; - -internal sealed class FakeOps : IClientStageOperations -{ - public List Responses { get; } = []; - public List Outbound { get; } = []; - - public void OnResponse(HttpResponseMessage r) => Responses.Add(r); - public void OnOutbound(ITransportOutbound item) => Outbound.Add(item); - public void OnScheduleTimer(string name, TimeSpan duration) { } - public void OnCancelTimer(string name) { } - public ILoggingAdapter Log => NoLogger.Instance; - public IActorRef StageActor { get; set; } = ActorRefs.Nobody; -} diff --git a/src/TurboHTTP.Tests.Shared/FakeResponse.cs b/src/TurboHTTP.Tests.Shared/FakeResponse.cs index 657b86730..061aa6dad 100644 --- a/src/TurboHTTP.Tests.Shared/FakeResponse.cs +++ b/src/TurboHTTP.Tests.Shared/FakeResponse.cs @@ -14,53 +14,14 @@ public static class FakeResponse [500] = "Internal Server Error", [502] = "Bad Gateway", [503] = "Service Unavailable" }; - private static string GetReason(int status) => - ReasonPhrases.TryGetValue(status, out var reason) ? reason : "Unknown"; + private static string GetReason(int status) => ReasonPhrases.GetValueOrDefault(status, "Unknown"); - public static byte[] Http10(int status, string? body = null, - params (string Name, string Value)[] headers) + public static byte[] Http10(int status, string? body = null, params (string Name, string Value)[] headers) => BuildHttp1("HTTP/1.0", status, body, headers); - public static byte[] Http11(int status, string? body = null, - params (string Name, string Value)[] headers) + public static byte[] Http11(int status, string? body = null, params (string Name, string Value)[] headers) => BuildHttp1("HTTP/1.1", status, body, headers); - public static byte[] Ok(string body) => Http11(200, body); - public static byte[] NotFound() => Http11(404); - - public static byte[] H2(int status, string? body = null, - params (string Name, string Value)[] headers) - { - var builder = new H2ResponseBuilder() - .Settings() - .SettingsAck() - .Headers(1, status, headers.Length > 0 ? headers.Select(h => (h.Name, h.Value)).ToList() : null, - endStream: body is null) - .WindowUpdate(0, 1_048_576); - - if (body is not null) - { - builder.Data(1, body); - } - - return builder.Build(); - } - - public static byte[] H3(int status, string? body = null, - params (string Name, string Value)[] headers) - { - var builder = new H3ResponseBuilder() - .Headers(status, headers.Length > 0 ? headers.Select(h => (h.Name, h.Value)).ToList() : null, - endStream: body is null); - - if (body is not null) - { - builder.Data(body); - } - - return builder.Build(); - } - private static byte[] BuildHttp1(string version, int status, string? body, (string Name, string Value)[] headers) { @@ -97,4 +58,4 @@ private static byte[] BuildHttp1(string version, int status, string? body, bodyBytes.CopyTo(result, headerBytes.Length); return result; } -} +} \ No newline at end of file diff --git a/src/TurboHTTP.Tests.Shared/FakeServerOps.cs b/src/TurboHTTP.Tests.Shared/FakeServerOps.cs index f72b3dd31..0f2c2000c 100644 --- a/src/TurboHTTP.Tests.Shared/FakeServerOps.cs +++ b/src/TurboHTTP.Tests.Shared/FakeServerOps.cs @@ -9,14 +9,13 @@ namespace TurboHTTP.Tests.Shared; internal sealed class FakeServerOps : IServerStageOperations { - private readonly List _features = []; + public List Requests { get; } = []; - public List Requests => _features; public List Outbound { get; } = []; public List<(string Name, TimeSpan Delay)> ScheduledTimers { get; } = []; public List CancelledTimers { get; } = []; - public void OnRequest(IFeatureCollection features) => _features.Add(features); + public void OnRequest(IFeatureCollection features) => Requests.Add(features); public void OnOutbound(ITransportOutbound item) => Outbound.Add(item); public void OnScheduleTimer(string name, TimeSpan delay) diff --git a/src/TurboHTTP.Tests.Shared/IAsyncLifetime.cs b/src/TurboHTTP.Tests.Shared/IAsyncLifetime.cs deleted file mode 100644 index 650128f91..000000000 --- a/src/TurboHTTP.Tests.Shared/IAsyncLifetime.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace TurboHTTP.Tests.Shared; - diff --git a/src/TurboHTTP.Tests.Shared/TurboHTTP.Tests.Shared.csproj b/src/TurboHTTP.Tests.Shared/TurboHTTP.Tests.Shared.csproj index 888f2413f..1aff0fc29 100644 --- a/src/TurboHTTP.Tests.Shared/TurboHTTP.Tests.Shared.csproj +++ b/src/TurboHTTP.Tests.Shared/TurboHTTP.Tests.Shared.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/TurboHTTP.Tests/Client/ClientOptionsProjectionsSpec.cs b/src/TurboHTTP.Tests/Client/ClientOptionsProjectionsSpec.cs new file mode 100644 index 000000000..7e433d53f --- /dev/null +++ b/src/TurboHTTP.Tests/Client/ClientOptionsProjectionsSpec.cs @@ -0,0 +1,79 @@ +using TurboHTTP.Client; + +namespace TurboHTTP.Tests.Client; + +public sealed class ClientOptionsProjectionsSpec +{ + [Fact(Timeout = 5000)] + public void Http2_max_frame_size_should_flow_to_encoder_options() + { + var o = new TurboClientOptions + { + Http2 = + { + MaxFrameSize = 32 * 1024 + } + }; + + var enc = o.ToHttp2EncoderOptions(); + + Assert.Equal(32 * 1024, enc.MaxFrameSize); + } + + [Fact(Timeout = 5000)] + public void Http2_default_max_frame_size_should_be_projected_not_dropped() + { + var enc = new TurboClientOptions().ToHttp2EncoderOptions(); + + Assert.Equal(64 * 1024, enc.MaxFrameSize); + } + + [Fact(Timeout = 5000)] + public void Http2_header_table_size_should_flow_to_encoder_options() + { + var o = new TurboClientOptions + { + Http2 = + { + HeaderTableSize = 8 * 1024 + } + }; + + var enc = o.ToHttp2EncoderOptions(); + + Assert.Equal(8 * 1024, enc.HeaderTableSize); + } + + [Fact(Timeout = 5000)] + public void Http2_adaptive_scaling_options_should_flow_to_decoder_options() + { + var o = new TurboClientOptions + { + Http2 = + { + InitialStreamWindowSize = 128 * 1024, + MaxStreamWindowSize = 8 * 1024 * 1024, + WindowScaleThresholdMultiplier = 2.0, + EnableAdaptiveWindowScaling = false, + } + }; + + var dec = o.ToHttp2DecoderOptions(); + + Assert.Equal(128 * 1024, dec.InitialStreamWindowSize); + Assert.Equal(8 * 1024 * 1024, dec.MaxStreamWindowSize); + Assert.Equal(2.0, dec.WindowScaleThresholdMultiplier); + Assert.False(dec.EnableAdaptiveWindowScaling); + } + + [Fact(Timeout = 5000)] + public void Http2_defaults_should_start_at_1mb_with_16mb_cap() + { + var dec = new TurboClientOptions().ToHttp2DecoderOptions(); + + Assert.Equal(1 * 1024 * 1024, dec.InitialStreamWindowSize); + Assert.Equal(16 * 1024 * 1024, dec.MaxStreamWindowSize); + Assert.Equal(1.0, dec.WindowScaleThresholdMultiplier); + Assert.True(dec.EnableAdaptiveWindowScaling); + } +} diff --git a/src/TurboHTTP.Tests/Client/TurboClientOptionsSpec.cs b/src/TurboHTTP.Tests/Client/TurboClientOptionsSpec.cs index 31f8c8af9..2d10c9e04 100644 --- a/src/TurboHTTP.Tests/Client/TurboClientOptionsSpec.cs +++ b/src/TurboHTTP.Tests/Client/TurboClientOptionsSpec.cs @@ -107,22 +107,22 @@ public void PooledConnectionLifetime_CanBeSet() } [Fact(Timeout = 5000)] - public void MaxEndpointSubstreams_DefaultIs256() + public void MaxConcurrentEndpoints_DefaultIs256() { var options = new TurboClientOptions(); - Assert.Equal(256u, options.MaxEndpointSubstreams); + Assert.Equal(256u, options.MaxConcurrentEndpoints); } [Fact(Timeout = 5000)] - public void MaxEndpointSubstreams_CanBeSet() + public void MaxConcurrentEndpoints_CanBeSet() { var options = new TurboClientOptions { - MaxEndpointSubstreams = 512 + MaxConcurrentEndpoints = 512 }; - Assert.Equal(512u, options.MaxEndpointSubstreams); + Assert.Equal(512u, options.MaxConcurrentEndpoints); } [Fact(Timeout = 5000)] @@ -290,6 +290,15 @@ public void PreAuthenticate_CanBeSet() Assert.True(options.PreAuthenticate); } + [Fact(Timeout = 5000)] + public void MaxRequestBodyBufferSize_default_should_be_64_KiB() + { + var o = new TurboClientOptions(); + + Assert.Equal(64 * 1024, o.Http2.MaxRequestBodyBufferSize); + Assert.Equal(64 * 1024, o.Http3.MaxRequestBodyBufferSize); + } + [Fact(Timeout = 5000)] public void EffectiveServerCertificateValidationCallback_WhenDangerousAcceptAnyServerCertificateFalse_ReturnsCustomCallback() diff --git a/src/TurboHTTP.Tests/Client/TurboClientServiceCollectionExtensionsSpec.cs b/src/TurboHTTP.Tests/Client/TurboClientServiceCollectionExtensionsSpec.cs index c583f7eca..cabda6e28 100644 --- a/src/TurboHTTP.Tests/Client/TurboClientServiceCollectionExtensionsSpec.cs +++ b/src/TurboHTTP.Tests/Client/TurboClientServiceCollectionExtensionsSpec.cs @@ -145,16 +145,16 @@ public void CreateClient_WithNullFactory_ThrowsArgumentNullException() public void AddTurboHttpClient_MultipleExtensions_AllConfigured() { var services = new ServiceCollection(); - services.AddTurboHttpClient("client1", opt => opt.MaxEndpointSubstreams = 100); - services.AddTurboHttpClient("client2", opt => opt.MaxEndpointSubstreams = 200); + services.AddTurboHttpClient("client1", opt => opt.MaxConcurrentEndpoints = 100); + services.AddTurboHttpClient("client2", opt => opt.MaxConcurrentEndpoints = 200); var sp = services.BuildServiceProvider(); var optionsMonitor = sp.GetRequiredService>(); var options1 = optionsMonitor.Get("client1"); var options2 = optionsMonitor.Get("client2"); - Assert.Equal(100u, options1.MaxEndpointSubstreams); - Assert.Equal(200u, options2.MaxEndpointSubstreams); + Assert.Equal(100u, options1.MaxConcurrentEndpoints); + Assert.Equal(200u, options2.MaxConcurrentEndpoints); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Client/TurboHttpClientBuilderExtensionsSpec.cs b/src/TurboHTTP.Tests/Client/TurboHttpClientBuilderExtensionsSpec.cs index 7b32258f9..89a786d88 100644 --- a/src/TurboHTTP.Tests/Client/TurboHttpClientBuilderExtensionsSpec.cs +++ b/src/TurboHTTP.Tests/Client/TurboHttpClientBuilderExtensionsSpec.cs @@ -235,7 +235,7 @@ public void WithRequestCompression_NoPolicy_CreatesDefaultPolicy() public void WithRequestCompression_WithPolicy_AssignsCompressionPolicy() { var services = new ServiceCollection(); - services.AddTurboHttpClient("test").WithRequestCompression(x => x.MinBodySizeBytes = 1024); + services.AddTurboHttpClient("test").WithRequestCompression(x => x.MinBodySize = 1024); var descriptor = GetDescriptor(services, "test"); @@ -269,7 +269,7 @@ public void WithExpectContinue_NoPolicy_CreatesDefaultPolicy() public void WithExpectContinue_WithPolicy_AssignsPolicy() { var services = new ServiceCollection(); - services.AddTurboHttpClient("test").WithExpectContinue(x => x.MinBodySizeBytes = 2048); + services.AddTurboHttpClient("test").WithExpectContinue(x => x.MinBodySize = 2048); var descriptor = GetDescriptor(services, "test"); diff --git a/src/TurboHTTP.Tests/Client/TurboHttpClientSpec.cs b/src/TurboHTTP.Tests/Client/TurboHttpClientSpec.cs index 0af36f22e..8f7e89982 100644 --- a/src/TurboHTTP.Tests/Client/TurboHttpClientSpec.cs +++ b/src/TurboHTTP.Tests/Client/TurboHttpClientSpec.cs @@ -185,6 +185,43 @@ public async Task SendAsync_should_timeout_when_no_response() Assert.NotNull(ex); } + [Fact(Timeout = 5000)] + public async Task SendAsync_should_honor_per_request_timeout_over_global() + { + var requests = Channel.CreateUnbounded(); + var responses = Channel.CreateUnbounded(); + + using var client = CreateTestClient(requests, responses, timeout: TimeSpan.FromSeconds(30)); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + .WithTimeout(TimeSpan.FromMilliseconds(100)); + + var sendTask = client.SendAsync(request, TestContext.Current.CancellationToken); + + // Read but never complete the request: only the per-request timeout can end it. + await requests.Reader.ReadAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAsync(() => sendTask); + Assert.NotNull(ex); + } + + [Fact(Timeout = 5000)] + public async Task SendAsync_should_honor_per_request_timeout_when_global_infinite() + { + var requests = Channel.CreateUnbounded(); + var responses = Channel.CreateUnbounded(); + + using var client = CreateTestClient(requests, responses, timeout: System.Threading.Timeout.InfiniteTimeSpan); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + .WithTimeout(TimeSpan.FromMilliseconds(100)); + + var sendTask = client.SendAsync(request, TestContext.Current.CancellationToken); + + await requests.Reader.ReadAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAsync(() => sendTask); + Assert.NotNull(ex); + } + [Fact(Timeout = 5000)] public async Task SendAsync_should_honor_cancellation_token() { diff --git a/src/TurboHTTP.Tests/Client/TypedClientRegistrationSpec.cs b/src/TurboHTTP.Tests/Client/TypedClientRegistrationSpec.cs new file mode 100644 index 000000000..5d941c4b4 --- /dev/null +++ b/src/TurboHTTP.Tests/Client/TypedClientRegistrationSpec.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using TurboHTTP.Client; + +namespace TurboHTTP.Tests.Client; + +public sealed class TypedClientRegistrationSpec +{ + private sealed class MyApiClient(ITurboHttpClient client) + { + public ITurboHttpClient Client { get; } = client; + } + + private interface IMyService + { + ITurboHttpClient Client { get; } + } + + private sealed class MyService(ITurboHttpClient client) : IMyService + { + public ITurboHttpClient Client { get; } = client; + } + + [Fact(Timeout = 10000)] + public void AddTurboHttpClient_typed_should_resolve_POCO_client() + { + var services = new ServiceCollection(); + services.AddTurboHttpClient(); + using var sp = services.BuildServiceProvider(); + + var client = sp.GetRequiredService(); + + Assert.NotNull(client); + Assert.NotNull(client.Client); + } + + [Fact(Timeout = 10000)] + public void AddTurboHttpClient_typed_with_interface_should_resolve_via_interface() + { + var services = new ServiceCollection(); + services.AddTurboHttpClient(); + using var sp = services.BuildServiceProvider(); + + var client = sp.GetRequiredService(); + + Assert.NotNull(client); + Assert.NotNull(client.Client); + } + + [Fact(Timeout = 10000)] + public void AddTurboHttpClient_typed_with_interface_should_resolve_impl_directly() + { + var services = new ServiceCollection(); + services.AddTurboHttpClient(); + using var sp = services.BuildServiceProvider(); + + var impl = sp.GetRequiredService(); + + Assert.NotNull(impl); + Assert.NotNull(impl.Client); + } +} diff --git a/src/TurboHTTP.Tests/Diagnostics/HexDumpFormatterSpec.cs b/src/TurboHTTP.Tests/Diagnostics/HexDumpFormatterSpec.cs deleted file mode 100644 index a518516c2..000000000 --- a/src/TurboHTTP.Tests/Diagnostics/HexDumpFormatterSpec.cs +++ /dev/null @@ -1,71 +0,0 @@ -using TurboHTTP.Diagnostics; - -namespace TurboHTTP.Tests.Diagnostics; - -public sealed class HexDumpFormatterSpec -{ - [Fact(Timeout = 5000)] - public void Format_should_return_empty_string_for_empty_input() - { - var result = HexDumpFormatter.Format(ReadOnlySpan.Empty); - Assert.Equal(string.Empty, result); - } - - [Fact(Timeout = 5000)] - public void Format_should_format_partial_line() - { - var data = "Hello"u8; - var result = HexDumpFormatter.Format(data); - - Assert.Contains("48 65 6C 6C 6F", result); - Assert.Contains("Hello", result); - Assert.Contains("00000000", result); - } - - [Fact(Timeout = 5000)] - public void Format_should_format_exact_16_byte_line() - { - var data = "0123456789ABCDEF"u8; - var result = HexDumpFormatter.Format(data); - - var lines = result.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - Assert.Single(lines); - Assert.Contains("00000000", lines[0]); - Assert.Contains("0123456789ABCDEF", lines[0]); - } - - [Fact(Timeout = 5000)] - public void Format_should_produce_multiple_lines_for_large_input() - { - var data = new byte[32]; - for (var i = 0; i < 32; i++) - { - data[i] = (byte)(0x41 + (i % 26)); - } - - var result = HexDumpFormatter.Format(data); - var lines = result.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - Assert.Equal(2, lines.Length); - Assert.Contains("00000000", lines[0]); - Assert.Contains("00000010", lines[1]); - } - - [Fact(Timeout = 5000)] - public void Format_should_show_non_printable_chars_as_dot() - { - var data = new byte[] { 0x00, 0x01, 0x1F, 0x7F, 0x80, 0xFF, 0x41, 0x42 }; - var result = HexDumpFormatter.Format(data); - - Assert.Contains("......AB", result); - } - - [Fact(Timeout = 5000)] - public void Format_should_separate_two_8_byte_groups_with_extra_space() - { - var data = new byte[16]; - var result = HexDumpFormatter.Format(data); - - Assert.Contains("00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00", result); - } -} diff --git a/src/TurboHTTP.Tests/Diagnostics/LoggerTraceListenerSpec.cs b/src/TurboHTTP.Tests/Diagnostics/LoggerTraceListenerSpec.cs index e528dd2f9..08e261ca5 100644 --- a/src/TurboHTTP.Tests/Diagnostics/LoggerTraceListenerSpec.cs +++ b/src/TurboHTTP.Tests/Diagnostics/LoggerTraceListenerSpec.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Tests.Diagnostics; diff --git a/src/TurboHTTP.Tests/Diagnostics/TurboHttpInstrumentationSpec.cs b/src/TurboHTTP.Tests/Diagnostics/TurboHttpInstrumentationSpec.cs index a67957f65..1221d772f 100644 --- a/src/TurboHTTP.Tests/Diagnostics/TurboHttpInstrumentationSpec.cs +++ b/src/TurboHTTP.Tests/Diagnostics/TurboHttpInstrumentationSpec.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using System.Net; using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Tests.Diagnostics; @@ -43,7 +43,7 @@ public void StartRequest_should_create_request_activity() var activity = Tracing.StartRequest(request); Assert.NotNull(activity); - Assert.Equal("TurboHTTP.Request", activity.OperationName); + Assert.Equal("TurboHTTP.ClientRequest", activity.OperationName); Assert.Equal(ActivityKind.Client, activity.Kind); } @@ -263,7 +263,7 @@ public void SetError_on_root_activity_should_set_all_attributes() Tracing.SetHttpError(activity, exception); - Assert.Equal("TurboHTTP.Request", activity.OperationName); + Assert.Equal("TurboHTTP.ClientRequest", activity.OperationName); Assert.Equal(typeof(HttpRequestException).FullName, activity.GetTagItem("exception.type")); Assert.Equal("Connection reset by peer", activity.GetTagItem("exception.message")); Assert.Equal(ActivityStatusCode.Error, activity.Status); @@ -286,9 +286,9 @@ public void RequestActivityKey_should_store_activity_in_request_options() var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); var activity = Tracing.StartRequest(request)!; - request.Options.Set(TurboHttpInstrumentationExtensions.RequestActivityKey, activity); + request.Options.Set(TurboClientInstrumentationExtensions.RequestActivityKey, activity); - Assert.True(request.Options.TryGetValue(TurboHttpInstrumentationExtensions.RequestActivityKey, + Assert.True(request.Options.TryGetValue(TurboClientInstrumentationExtensions.RequestActivityKey, out var retrieved)); Assert.Same(activity, retrieved); } @@ -300,7 +300,7 @@ public void FullLifecycle_with_redirect_and_retry_events() var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/start"); var rootActivity = Tracing.StartRequest(request)!; - request.Options.Set(TurboHttpInstrumentationExtensions.RequestActivityKey, rootActivity); + request.Options.Set(TurboClientInstrumentationExtensions.RequestActivityKey, rootActivity); Tracing.AddRedirectEvent(rootActivity, new Uri("https://example.com/hop1"), 301); Tracing.AddRetryEvent(rootActivity, 1); @@ -327,14 +327,14 @@ public void FullLifecycle_with_error() var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/fail"); var rootActivity = Tracing.StartRequest(request)!; - request.Options.Set(TurboHttpInstrumentationExtensions.RequestActivityKey, rootActivity); + request.Options.Set(TurboClientInstrumentationExtensions.RequestActivityKey, rootActivity); var exception = new HttpRequestException("Connection refused"); Tracing.SetHttpError(rootActivity, exception); rootActivity.Stop(); Assert.Single(_activities); - Assert.Equal("TurboHTTP.Request", rootActivity.OperationName); + Assert.Equal("TurboHTTP.ClientRequest", rootActivity.OperationName); Assert.Equal(ActivityStatusCode.Error, rootActivity.Status); Assert.Equal("Connection refused", rootActivity.GetTagItem("exception.message")); Assert.True(rootActivity.IsStopped); @@ -421,28 +421,28 @@ public void ActivitySource_should_have_version() public void RedactUrl_should_replace_query_with_asterisk() { var uri = new Uri("https://example.com/path?secret=abc&token=xyz"); - Assert.Equal("https://example.com/path?*", TurboHttpInstrumentationExtensions.RedactUrl(uri)); + Assert.Equal("https://example.com/path?*", TurboClientInstrumentationExtensions.RedactUrl(uri)); } [Fact(Timeout = 5000)] public void RedactUrl_should_preserve_url_without_query() { var uri = new Uri("https://example.com/path"); - Assert.Equal("https://example.com/path", TurboHttpInstrumentationExtensions.RedactUrl(uri)); + Assert.Equal("https://example.com/path", TurboClientInstrumentationExtensions.RedactUrl(uri)); } [Fact(Timeout = 5000)] public void RedactUrl_should_strip_fragment() { var uri = new Uri("https://example.com/path#section"); - Assert.Equal("https://example.com/path", TurboHttpInstrumentationExtensions.RedactUrl(uri)); + Assert.Equal("https://example.com/path", TurboClientInstrumentationExtensions.RedactUrl(uri)); } [Fact(Timeout = 5000)] public void RedactUrl_should_strip_fragment_and_redact_query() { var uri = new Uri("https://example.com/path?q=1#frag"); - Assert.Equal("https://example.com/path?*", TurboHttpInstrumentationExtensions.RedactUrl(uri)); + Assert.Equal("https://example.com/path?*", TurboClientInstrumentationExtensions.RedactUrl(uri)); } [Theory] @@ -457,7 +457,7 @@ public void RedactUrl_should_strip_fragment_and_redact_query() [InlineData("CONNECT", "CONNECT")] public void NormalizeMethod_should_return_standard_methods_uppercased(string input, string expected) { - Assert.Equal(expected, TurboHttpInstrumentationExtensions.NormalizeMethod(input)); + Assert.Equal(expected, TurboClientInstrumentationExtensions.NormalizeMethod(input)); } [Theory] @@ -466,7 +466,7 @@ public void NormalizeMethod_should_return_standard_methods_uppercased(string inp [InlineData("CUSTOM")] public void NormalizeMethod_should_return_OTHER_for_nonstandard(string method) { - Assert.Equal("_OTHER", TurboHttpInstrumentationExtensions.NormalizeMethod(method)); + Assert.Equal("_OTHER", TurboClientInstrumentationExtensions.NormalizeMethod(method)); } [Fact(Timeout = 5000)] @@ -500,7 +500,7 @@ public void StartRequest_should_not_set_method_original_for_standard() [InlineData(3, 0, "3")] public void FormatProtocolVersion_should_return_correct_format(int major, int minor, string expected) { - Assert.Equal(expected, TurboHttpInstrumentationExtensions.FormatProtocolVersion(new Version(major, minor))); + Assert.Equal(expected, TurboClientInstrumentationExtensions.FormatProtocolVersion(new Version(major, minor))); } [Fact(Timeout = 5000)] @@ -586,7 +586,7 @@ public void IsTracingActive_should_return_true_when_listener_present() public void RedactUrl_should_handle_empty_query() { var uri = new Uri("https://example.com/path?"); - Assert.Equal("https://example.com/path?*", TurboHttpInstrumentationExtensions.RedactUrl(uri)); + Assert.Equal("https://example.com/path?*", TurboClientInstrumentationExtensions.RedactUrl(uri)); } [Fact(Timeout = 5000)] @@ -594,7 +594,7 @@ public void RedactUrl_with_complex_path_should_preserve_structure() { var uri = new Uri("https://api.example.com:8080/v1/users/123/profile?token=secret#top"); Assert.Equal("https://api.example.com:8080/v1/users/123/profile?*", - TurboHttpInstrumentationExtensions.RedactUrl(uri)); + TurboClientInstrumentationExtensions.RedactUrl(uri)); } [Fact(Timeout = 5000)] @@ -621,16 +621,16 @@ public void SetResponse_with_3xx_status_should_not_set_error() [Fact(Timeout = 5000)] public void NormalizeMethod_should_handle_lowercase_standard_methods() { - Assert.Equal("GET", TurboHttpInstrumentationExtensions.NormalizeMethod("get")); - Assert.Equal("POST", TurboHttpInstrumentationExtensions.NormalizeMethod("post")); - Assert.Equal("PUT", TurboHttpInstrumentationExtensions.NormalizeMethod("put")); + Assert.Equal("GET", TurboClientInstrumentationExtensions.NormalizeMethod("get")); + Assert.Equal("POST", TurboClientInstrumentationExtensions.NormalizeMethod("post")); + Assert.Equal("PUT", TurboClientInstrumentationExtensions.NormalizeMethod("put")); } [Fact(Timeout = 5000)] public void NormalizeMethod_should_handle_mixed_case() { - Assert.Equal("GET", TurboHttpInstrumentationExtensions.NormalizeMethod("Get")); - Assert.Equal("POST", TurboHttpInstrumentationExtensions.NormalizeMethod("PoSt")); + Assert.Equal("GET", TurboClientInstrumentationExtensions.NormalizeMethod("Get")); + Assert.Equal("POST", TurboClientInstrumentationExtensions.NormalizeMethod("PoSt")); } [Fact(Timeout = 5000)] @@ -647,7 +647,7 @@ public void StartRequest_should_set_url_scheme_for_http() [Fact(Timeout = 5000)] public void FormatProtocolVersion_should_handle_version_3_with_minor() { - Assert.Equal("3", TurboHttpInstrumentationExtensions.FormatProtocolVersion(new Version(3, 1))); + Assert.Equal("3", TurboClientInstrumentationExtensions.FormatProtocolVersion(new Version(3, 1))); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Diagnostics/TurboHttpMetricsSpec.cs b/src/TurboHTTP.Tests/Diagnostics/TurboHttpMetricsSpec.cs index 40d3bfe0a..ac381e5ca 100644 --- a/src/TurboHTTP.Tests/Diagnostics/TurboHttpMetricsSpec.cs +++ b/src/TurboHTTP.Tests/Diagnostics/TurboHttpMetricsSpec.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.Metrics; using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Tests.Diagnostics; diff --git a/src/TurboHTTP.Tests/Diagnostics/TurboServerInstrumentationSpec.cs b/src/TurboHTTP.Tests/Diagnostics/TurboServerInstrumentationSpec.cs index bb72512a0..aff1d0a5c 100644 --- a/src/TurboHTTP.Tests/Diagnostics/TurboServerInstrumentationSpec.cs +++ b/src/TurboHTTP.Tests/Diagnostics/TurboServerInstrumentationSpec.cs @@ -1,6 +1,6 @@ using System.Diagnostics; using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Tests.Diagnostics; @@ -246,4 +246,50 @@ public void StartConnectionActivity_should_return_null_when_no_listener() Assert.Null(activity); } + + [Fact(Timeout = 5000)] + public void StartRequestActivity_should_extract_traceparent_as_parent_context() + { + _ = Tracing.StartConnectionActivity("127.0.0.1", 8080, "tcp")!; + + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var traceparent = $"00-{traceId}-{spanId}-01"; + + var reqActivity = Tracing.StartRequestActivity("GET", "/", "http", traceparent, null)!; + + Assert.Equal(traceId.ToString(), reqActivity.TraceId.ToString()); + Assert.Equal(spanId.ToString(), reqActivity.ParentSpanId.ToString()); + + reqActivity.Stop(); + } + + [Fact(Timeout = 5000)] + public void StartRequestActivity_should_propagate_tracestate() + { + _ = Tracing.StartConnectionActivity("127.0.0.1", 8080, "tcp")!; + + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var traceparent = $"00-{traceId}-{spanId}-01"; + + var reqActivity = Tracing.StartRequestActivity("GET", "/", "http", traceparent, "congo=t61rcWkgMzE")!; + + Assert.Equal("congo=t61rcWkgMzE", reqActivity.TraceStateString); + + reqActivity.Stop(); + } + + [Fact(Timeout = 5000)] + public void StartRequestActivity_should_work_without_traceparent() + { + _ = Tracing.StartConnectionActivity("127.0.0.1", 8080, "tcp")!; + + var reqActivity = Tracing.StartRequestActivity("GET", "/", "http", null, null)!; + + Assert.NotNull(reqActivity); + Assert.False(reqActivity.HasRemoteParent); + + reqActivity.Stop(); + } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Diagnostics/TurboServerMetricsSpec.cs b/src/TurboHTTP.Tests/Diagnostics/TurboServerMetricsSpec.cs index 6ccdce082..f509d4153 100644 --- a/src/TurboHTTP.Tests/Diagnostics/TurboServerMetricsSpec.cs +++ b/src/TurboHTTP.Tests/Diagnostics/TurboServerMetricsSpec.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.Metrics; using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Tests.Diagnostics; @@ -53,7 +53,7 @@ public void ActiveConnections_should_increment_and_decrement() _listener.RecordObservableInstruments(); - var measurements = GetLongMeasurements("kestrel.active_connections"); + var measurements = GetLongMeasurements("turbo.server.active_connections"); Assert.Equal(2, measurements.Count); Assert.Equal(0, measurements.Sum(m => m.Value)); } @@ -70,7 +70,7 @@ public void ConnectionDuration_should_record_seconds() _listener.RecordObservableInstruments(); - var m = Assert.Single(GetDoubleMeasurements("kestrel.connection.duration")); + var m = Assert.Single(GetDoubleMeasurements("turbo.server.connection.duration")); Assert.Equal(1.5, m.Value); } @@ -85,7 +85,7 @@ public void RejectedConnections_should_increment() _listener.RecordObservableInstruments(); - var m = Assert.Single(GetLongMeasurements("kestrel.rejected_connections")); + var m = Assert.Single(GetLongMeasurements("turbo.server.rejected_connections")); Assert.Equal(1, m.Value); } @@ -100,7 +100,7 @@ public void TlsHandshakeDuration_should_record() _listener.RecordObservableInstruments(); - var m = Assert.Single(GetDoubleMeasurements("kestrel.tls_handshake.duration")); + var m = Assert.Single(GetDoubleMeasurements("turbo.server.tls_handshake.duration")); Assert.Equal(0.05, m.Value); } @@ -119,7 +119,7 @@ public void ActiveTlsHandshakes_should_increment_and_decrement() _listener.RecordObservableInstruments(); - var measurements = GetLongMeasurements("kestrel.active_tls_handshakes"); + var measurements = GetLongMeasurements("turbo.server.active_tls_handshakes"); Assert.Equal(2, measurements.Count); Assert.Equal(0, measurements.Sum(m => m.Value)); } diff --git a/src/TurboHTTP.Tests/Diagnostics/TurboTraceExtensionsSpec.cs b/src/TurboHTTP.Tests/Diagnostics/TurboTraceExtensionsSpec.cs index cba125158..4250022d9 100644 --- a/src/TurboHTTP.Tests/Diagnostics/TurboTraceExtensionsSpec.cs +++ b/src/TurboHTTP.Tests/Diagnostics/TurboTraceExtensionsSpec.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Tests.Diagnostics; diff --git a/src/TurboHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs b/src/TurboHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs index 36026689b..e3a63fa31 100644 --- a/src/TurboHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs @@ -23,6 +23,15 @@ private static HttpResponseMessage ResponseWithCookie(string setCookie) : null; } + private static string? GetCrossSiteCookieHeader(CookieJar jar, string url, string firstParty, bool isSafeMethod) + { + var req = new HttpRequestMessage(isSafeMethod ? HttpMethod.Get : HttpMethod.Post, url); + jar.AddCookiesToRequest(Uri(url), ref req, Uri(firstParty), isSafeMethod); + return req.Headers.TryGetValues("Cookie", out var values) + ? string.Join("", values) + : null; + } + [Fact] public void CookieJar_should_not_send_secure_cookie_when_request_is_http() { @@ -90,8 +99,8 @@ public void CookieJar_should_store_httponly_flag_when_set_cookie_contains_httpon [Fact] public void CookieJar_should_store_samesite_strict_when_set_cookie_contains_strict() { - // SameSite=Strict cookies are stored. The jar stores the attribute; enforcement of - // cross-site exclusion is the caller's responsibility (CookieBidiStage). + // SameSite=Strict cookies are stored and sent on same-site requests. Cross-site exclusion + // is enforced when a first-party context is supplied (see the cross-site tests below). var jar = new CookieJar(); jar.ProcessResponse( Uri("https://example.com/"), @@ -120,6 +129,96 @@ public void CookieJar_should_store_samesite_lax_when_set_cookie_contains_lax() Assert.Contains("pref=dark", cookie); } + [Fact] + public void CookieJar_should_not_send_strict_cookie_when_request_is_cross_site() + { + // Attack: a cross-site request (initiated by other.com) must not carry a SameSite=Strict + // cookie scoped to example.com — this is the CSRF protection SameSite=Strict provides. + var jar = new CookieJar(); + jar.ProcessResponse( + Uri("https://example.com/"), + ResponseWithCookie("csrf=token123; SameSite=Strict; Secure")); + + var cookie = GetCrossSiteCookieHeader(jar, "https://example.com/", "https://other.com/", isSafeMethod: true); + + Assert.Null(cookie); + } + + [Fact] + public void CookieJar_should_send_strict_cookie_when_request_is_same_site() + { + // Same-site request (initiated by example.com) carries the Strict cookie. + var jar = new CookieJar(); + jar.ProcessResponse( + Uri("https://example.com/"), + ResponseWithCookie("csrf=token123; SameSite=Strict; Secure")); + + var cookie = GetCrossSiteCookieHeader(jar, "https://example.com/", "https://example.com/", isSafeMethod: true); + + Assert.NotNull(cookie); + Assert.Contains("csrf=token123", cookie); + } + + [Fact] + public void CookieJar_should_not_send_lax_cookie_when_cross_site_unsafe_method() + { + // SameSite=Lax cookies are withheld on cross-site unsafe (POST) requests. + var jar = new CookieJar(); + jar.ProcessResponse( + Uri("https://example.com/"), + ResponseWithCookie("pref=dark; SameSite=Lax")); + + var cookie = GetCrossSiteCookieHeader(jar, "https://example.com/", "https://other.com/", isSafeMethod: false); + + Assert.Null(cookie); + } + + [Fact] + public void CookieJar_should_send_lax_cookie_when_cross_site_safe_method() + { + // SameSite=Lax cookies ARE sent on cross-site safe top-level navigations (GET). + var jar = new CookieJar(); + jar.ProcessResponse( + Uri("https://example.com/"), + ResponseWithCookie("pref=dark; SameSite=Lax")); + + var cookie = GetCrossSiteCookieHeader(jar, "https://example.com/", "https://other.com/", isSafeMethod: true); + + Assert.NotNull(cookie); + Assert.Contains("pref=dark", cookie); + } + + [Fact] + public void CookieJar_should_send_none_cookie_when_cross_site() + { + // SameSite=None (with Secure) is intended for cross-site use and is always sent. + var jar = new CookieJar(); + jar.ProcessResponse( + Uri("https://example.com/"), + ResponseWithCookie("tracker=abc; SameSite=None; Secure")); + + var cookie = GetCrossSiteCookieHeader(jar, "https://example.com/", "https://other.com/", isSafeMethod: false); + + Assert.NotNull(cookie); + Assert.Contains("tracker=abc", cookie); + } + + [Fact] + public void CookieJar_should_treat_subdomain_as_same_site_for_strict_cookie() + { + // Same registrable domain (app.example.com vs api.example.com) is same-site: + // the Strict cookie must still flow between subdomains of one site. + var jar = new CookieJar(); + jar.ProcessResponse( + Uri("https://api.example.com/"), + ResponseWithCookie("csrf=token123; SameSite=Strict; Secure; Domain=example.com")); + + var cookie = GetCrossSiteCookieHeader(jar, "https://api.example.com/", "https://app.example.com/", isSafeMethod: false); + + Assert.NotNull(cookie); + Assert.Contains("csrf=token123", cookie); + } + [Fact] public void CookieJar_should_store_samesite_none_when_set_cookie_contains_none() { diff --git a/src/TurboHTTP.Tests/Features/Cookies/Stages/CookieBidiStageSpec.cs b/src/TurboHTTP.Tests/Features/Cookies/Stages/CookieBidiStageSpec.cs index aeb82b787..74c3739f2 100644 --- a/src/TurboHTTP.Tests/Features/Cookies/Stages/CookieBidiStageSpec.cs +++ b/src/TurboHTTP.Tests/Features/Cookies/Stages/CookieBidiStageSpec.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Akka.Streams; using Akka.Streams.Dsl; +using TurboHTTP.Client; using TurboHTTP.Features.Cookies; using TurboHTTP.Streams.Stages.Features; using TurboHTTP.Tests.Shared; @@ -111,6 +112,47 @@ public async Task CookieBidiStage_should_inject_cookie_when_matching_cookie_in_j Assert.Contains("session=abc123", cookieValue); } + private static CookieJar JarWithStrictCookie(string name, string value, string domain) + { + var jar = new CookieJar(); + var response = new HttpResponseMessage(); + response.Headers.TryAddWithoutValidation( + "Set-Cookie", $"{name}={value}; Domain={domain}; Path=/; SameSite=Strict"); + jar.ProcessResponse(new Uri($"http://{domain}/"), response); + return jar; + } + + [Fact(Timeout = 10_000)] + [Trait("RFC", "RFC6265-5.4")] + public async Task CookieBidiStage_should_not_inject_strict_cookie_when_request_is_cross_site() + { + var jar = JarWithStrictCookie("csrf", "token123", "example.com"); + var stage = new CookieBidiStage(jar); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path") + .WithFirstPartyContext(new Uri("http://other.com/")); + + var results = await RunRequestAsync(stage, request); + + var result = Assert.Single(results); + Assert.False(result.Headers.Contains("Cookie")); + } + + [Fact(Timeout = 10_000)] + [Trait("RFC", "RFC6265-5.4")] + public async Task CookieBidiStage_should_inject_strict_cookie_when_request_is_same_site() + { + var jar = JarWithStrictCookie("csrf", "token123", "example.com"); + var stage = new CookieBidiStage(jar); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path") + .WithFirstPartyContext(new Uri("http://example.com/")); + + var results = await RunRequestAsync(stage, request); + + var result = Assert.Single(results); + Assert.True(result.Headers.Contains("Cookie")); + Assert.Contains("csrf=token123", string.Join("; ", result.Headers.GetValues("Cookie"))); + } + [Fact(Timeout = 10_000)] [Trait("RFC", "RFC6265-5.4")] public async Task CookieBidiStage_should_not_add_cookie_header_when_jar_is_empty() diff --git a/src/TurboHTTP.Tests/Features/Sse/SseParserFlowSpec.cs b/src/TurboHTTP.Tests/Features/Sse/SseParserFlowSpec.cs index fa65647bd..d0785e7bf 100644 --- a/src/TurboHTTP.Tests/Features/Sse/SseParserFlowSpec.cs +++ b/src/TurboHTTP.Tests/Features/Sse/SseParserFlowSpec.cs @@ -102,11 +102,10 @@ public async Task Flow_should_handle_crlf_line_endings() [Fact(Timeout = 5000)] public async Task Flow_should_handle_split_across_chunks() { - var result = await Source.From(new[] - { + var result = await Source.From([ (ReadOnlyMemory)"data: hel"u8.ToArray(), (ReadOnlyMemory)"lo\n\n"u8.ToArray() - }) + ]) .Via(SseParserFlow.Instance) .RunWith(Sink.Seq(), _materializer); diff --git a/src/TurboHTTP.Tests/Internal/OptionsFactorySpec.cs b/src/TurboHTTP.Tests/Internal/OptionsFactorySpec.cs index 12c865990..40455732f 100644 --- a/src/TurboHTTP.Tests/Internal/OptionsFactorySpec.cs +++ b/src/TurboHTTP.Tests/Internal/OptionsFactorySpec.cs @@ -274,20 +274,6 @@ public void OptionsFactory_should_set_target_host_for_tls_options() Assert.Equal("example.com", options.TargetHost); } - [Fact(Timeout = 5000)] - public void OptionsFactory_should_preserve_http3_connection_migration_setting() - { - var endpoint = CreateHttp3Endpoint(); - var clientOptions = new TurboClientOptions - { - Http3 = new Http3Options { AllowConnectionMigration = false } - }; - - var options = (QuicTransportOptions)OptionsFactory.Build(endpoint, clientOptions); - - Assert.False(options.AllowConnectionMigration); - } - [Fact(Timeout = 5000)] public void OptionsFactory_should_handle_wss_scheme_as_https() { diff --git a/src/TurboHTTP.Tests/Protocol/Body/BodyReaderFactorySpec.cs b/src/TurboHTTP.Tests/Protocol/Body/BodyReaderFactorySpec.cs new file mode 100644 index 000000000..8e753dab6 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/BodyReaderFactorySpec.cs @@ -0,0 +1,76 @@ +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Semantics; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class BodyReaderFactorySpec +{ + private static readonly BodyDecoderOptions DefaultOptions = new() + { + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 64 * 1024, + MaxStreamedBodySize = 8 * 1024 * 1024, + MaxChunkExtensionLength = 256 + }; + + [Fact(Timeout = 5000)] + public void Create_should_return_null_reader_for_no_body() + { + var classification = new BodyClassification(BodyFraming.None, null); + var (reader, decoder) = BodyReaderFactory.Create(classification, DefaultOptions); + + Assert.Null(reader); + Assert.Null(decoder); + } + + [Fact(Timeout = 5000)] + public void Create_should_return_buffered_reader_for_small_content_length() + { + var classification = new BodyClassification(BodyFraming.Length, 100); + var (reader, decoder) = BodyReaderFactory.Create(classification, DefaultOptions); + + Assert.NotNull(reader); + Assert.IsType(reader); + Assert.Null(decoder); + reader.Dispose(); + } + + [Fact(Timeout = 5000)] + public void Create_should_return_queued_reader_with_content_length_decoder_for_large_body() + { + var classification = new BodyClassification(BodyFraming.Length, 128 * 1024); + var (reader, decoder) = BodyReaderFactory.Create(classification, DefaultOptions); + + Assert.NotNull(reader); + Assert.IsType(reader); + Assert.NotNull(decoder); + Assert.IsType(decoder); + reader.Dispose(); + } + + [Fact(Timeout = 5000)] + public void Create_should_return_queued_reader_with_chunked_decoder() + { + var classification = new BodyClassification(BodyFraming.Chunked, null); + var (reader, decoder) = BodyReaderFactory.Create(classification, DefaultOptions); + + Assert.NotNull(reader); + Assert.IsType(reader); + Assert.NotNull(decoder); + Assert.IsType(decoder); + reader.Dispose(); + } + + [Fact(Timeout = 5000)] + public void Create_should_return_queued_reader_with_close_delimited_decoder() + { + var classification = new BodyClassification(BodyFraming.Close, null); + var (reader, decoder) = BodyReaderFactory.Create(classification, DefaultOptions); + + Assert.NotNull(reader); + Assert.IsType(reader); + Assert.NotNull(decoder); + Assert.IsType(decoder); + reader.Dispose(); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/BufferedBodyReaderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/BufferedBodyReaderSpec.cs new file mode 100644 index 000000000..d8e69613b --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/BufferedBodyReaderSpec.cs @@ -0,0 +1,84 @@ +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class BufferedBodyReaderSpec +{ + [Fact(Timeout = 5000)] + public void Feed_should_complete_when_all_bytes_received() + { + using var reader = new BufferedBodyReader(); + reader.Reset(5); + + var consumed = reader.Feed("hello"u8); + + Assert.Equal(5, consumed); + Assert.True(reader.IsCompleted); + Assert.True(reader.IsBuffered); + } + + [Fact(Timeout = 5000)] + public void Feed_should_accumulate_across_multiple_calls() + { + using var reader = new BufferedBodyReader(); + reader.Reset(5); + + Assert.Equal(2, reader.Feed("he"u8)); + Assert.False(reader.IsCompleted); + Assert.Equal(3, reader.Feed("llo!extra"u8)); + Assert.True(reader.IsCompleted); + } + + [Fact(Timeout = 5000)] + public void GetBody_should_return_accumulated_bytes() + { + using var reader = new BufferedBodyReader(); + reader.Reset(3); + + reader.Feed("ab"u8); + reader.Feed("cdef"u8); + + Assert.Equal("abc"u8.ToArray(), reader.GetBody().ToArray()); + } + + [Fact(Timeout = 5000)] + public void Reset_should_allow_reuse_for_next_request() + { + using var reader = new BufferedBodyReader(); + reader.Reset(3); + reader.Feed("abc"u8); + Assert.True(reader.IsCompleted); + + reader.Reset(2); + Assert.False(reader.IsCompleted); + reader.Feed("xy"u8); + Assert.True(reader.IsCompleted); + Assert.Equal("xy"u8.ToArray(), reader.GetBody().ToArray()); + } + + [Fact(Timeout = 5000)] + public void Zero_length_body_should_complete_immediately() + { + using var reader = new BufferedBodyReader(); + reader.Reset(0); + + Assert.True(reader.IsCompleted); + Assert.Equal(0, reader.Feed(ReadOnlySpan.Empty)); + } + + [Fact(Timeout = 5000)] + public async Task AsStream_should_return_readable_stream_with_buffered_content() + { + using var reader = new BufferedBodyReader(); + reader.Reset(5); + reader.Feed("hello"u8); + + var stream = reader.AsStream(); + var buffer = new byte[16]; + var read = await stream.ReadAsync(buffer, TestContext.Current.CancellationToken); + + Assert.Equal(5, read); + Assert.Equal("hello"u8.ToArray(), buffer[..5]); + } + +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/BufferedBodyWriterSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/BufferedBodyWriterSpec.cs new file mode 100644 index 000000000..ab90eeb88 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/BufferedBodyWriterSpec.cs @@ -0,0 +1,96 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class BufferedBodyWriterSpec +{ + [Fact(Timeout = 5000)] + public async Task CompleteAsync_should_send_accumulated_data_via_callback() + { + IMemoryOwner? sentOwner = null; + var sentLength = 0; + + using var writer = new BufferedBodyWriter(); + writer.Reset((owner, length) => + { + sentOwner = owner; + sentLength = length; + }); + + var mem = writer.GetMemory(5); + "hello"u8.CopyTo(mem.Span); + writer.Advance(5); + + await writer.CompleteAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(sentOwner); + Assert.Equal(5, sentLength); + Assert.Equal("hello"u8.ToArray(), sentOwner!.Memory[..sentLength].ToArray()); + sentOwner.Dispose(); + } + + [Fact(Timeout = 5000)] + public async Task FlushAsync_should_be_noop_for_buffered_writer() + { + using var writer = new BufferedBodyWriter(); + writer.Reset((_, _) => { }); + + var mem = writer.GetMemory(3); + "abc"u8.CopyTo(mem.Span); + writer.Advance(3); + + var result = await writer.FlushAsync(TestContext.Current.CancellationToken); + Assert.False(result.IsCompleted); + } + + [Fact(Timeout = 5000)] + public async Task GetMemory_should_grow_buffer_when_needed() + { + IMemoryOwner? sentOwner = null; + var sentLength = 0; + + using var writer = new BufferedBodyWriter(); + writer.Reset((owner, length) => + { + sentOwner = owner; + sentLength = length; + }); + + for (var i = 0; i < 100; i++) + { + var mem = writer.GetMemory(64); + var data = new byte[64]; + Array.Fill(data, (byte)(i % 256)); + data.CopyTo(mem); + writer.Advance(64); + } + + await writer.CompleteAsync(TestContext.Current.CancellationToken); + + Assert.Equal(6400, sentLength); + sentOwner?.Dispose(); + } + + [Fact(Timeout = 5000)] + public async Task Reset_should_allow_reuse() + { + var callCount = 0; + + using var writer = new BufferedBodyWriter(); + + writer.Reset((owner, _) => { callCount++; owner.Dispose(); }); + var m1 = writer.GetMemory(3); + "abc"u8.CopyTo(m1.Span); + writer.Advance(3); + await writer.CompleteAsync(TestContext.Current.CancellationToken); + + writer.Reset((owner, _) => { callCount++; owner.Dispose(); }); + var m2 = writer.GetMemory(2); + "xy"u8.CopyTo(m2.Span); + writer.Advance(2); + await writer.CompleteAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, callCount); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/ChunkedFramingDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/ChunkedFramingDecoderSpec.cs new file mode 100644 index 000000000..3d9e334e3 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/ChunkedFramingDecoderSpec.cs @@ -0,0 +1,135 @@ +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class ChunkedFramingDecoderSpec +{ + [Fact(Timeout = 5000)] + public void Decode_should_parse_single_chunk_and_terminator() + { + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + + var input = "5\r\nhello\r\n0\r\n\r\n"u8; + var bodyBytes = new List(); + var pos = 0; + + while (!decoder.IsComplete && pos < input.Length) + { + var result = decoder.Decode(input[pos..], out var consumed); + if (!result.Body.IsEmpty) + { + bodyBytes.AddRange(result.Body.ToArray()); + } + + pos += consumed; + } + + Assert.True(decoder.IsComplete); + Assert.Equal("hello"u8.ToArray(), bodyBytes.ToArray()); + } + + [Fact(Timeout = 5000)] + public void Decode_should_parse_multiple_chunks() + { + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + + var input = "3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n"u8; + var bodyBytes = new List(); + var pos = 0; + + while (!decoder.IsComplete && pos < input.Length) + { + var result = decoder.Decode(input[pos..], out var consumed); + if (!result.Body.IsEmpty) + { + bodyBytes.AddRange(result.Body.ToArray()); + } + + pos += consumed; + } + + Assert.True(decoder.IsComplete); + Assert.Equal("abcde"u8.ToArray(), bodyBytes.ToArray()); + } + + [Fact(Timeout = 5000)] + public void Decode_should_handle_partial_input_across_calls() + { + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + + var bodyBytes = new List(); + + var r1 = decoder.Decode("5\r\nhel"u8, out _); + if (!r1.Body.IsEmpty) bodyBytes.AddRange(r1.Body.ToArray()); + + var r2 = decoder.Decode("lo\r\n0\r\n\r\n"u8, out _); + if (!r2.Body.IsEmpty) bodyBytes.AddRange(r2.Body.ToArray()); + + Assert.True(decoder.IsComplete); + Assert.Equal("hello"u8.ToArray(), bodyBytes.ToArray()); + } + + [Fact(Timeout = 5000)] + public void Decode_should_collect_trailers() + { + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + + var input = "0\r\nX-Checksum: abc123\r\n\r\n"u8; + decoder.Decode(input, out _); + + Assert.True(decoder.IsComplete); + Assert.Single(decoder.Trailers); + Assert.Equal("X-Checksum", decoder.Trailers[0].Name); + Assert.Equal("abc123", decoder.Trailers[0].Value); + } + + [Fact(Timeout = 5000)] + public void Decode_should_reject_invalid_chunk_size() + { + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + + Assert.Throws( + () => decoder.Decode("ZZZZ\r\n"u8, out _)); + } + + [Fact(Timeout = 5000)] + public void Decode_should_handle_chunk_extensions() + { + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + + var input = "3;ext=val\r\nabc\r\n0\r\n\r\n"u8; + var bodyBytes = new List(); + var pos = 0; + + while (!decoder.IsComplete && pos < input.Length) + { + var result = decoder.Decode(input[pos..], out var consumed); + if (!result.Body.IsEmpty) + { + bodyBytes.AddRange(result.Body.ToArray()); + } + + pos += consumed; + } + + Assert.Equal("abc"u8.ToArray(), bodyBytes.ToArray()); + } + + [Fact(Timeout = 5000)] + public void Reset_should_allow_reuse() + { + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + decoder.Decode("0\r\n\r\n"u8, out _); + Assert.True(decoder.IsComplete); + + decoder.Reset(long.MaxValue, maxChunkExtensionLength: 256); + Assert.False(decoder.IsComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/ChunkedFramingEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/ChunkedFramingEncoderSpec.cs new file mode 100644 index 000000000..5154f185c --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/ChunkedFramingEncoderSpec.cs @@ -0,0 +1,51 @@ +using System.Buffers; +using System.Text; +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class ChunkedFramingEncoderSpec +{ + [Fact(Timeout = 5000)] + public void Headroom_should_accommodate_max_hex_digits_plus_crlf() + { + var encoder = new ChunkedFramingEncoder(maxChunkSize: 4 * 1024); + Assert.True(encoder.Headroom >= 3 + 2); + } + + [Fact(Timeout = 5000)] + public void Trailer_should_be_two_for_crlf() + { + var encoder = new ChunkedFramingEncoder(maxChunkSize: 4 * 1024); + Assert.Equal(2, encoder.Trailer); + } + + [Fact(Timeout = 5000)] + public void Frame_should_produce_valid_chunked_encoding() + { + var encoder = new ChunkedFramingEncoder(maxChunkSize: 4 * 1024); + var headroom = encoder.Headroom; + var dataLen = 5; + var totalSize = headroom + dataLen + encoder.Trailer; + var owner = MemoryPool.Shared.Rent(totalSize); + + "hello"u8.CopyTo(owner.Memory.Span[headroom..]); + + var framed = encoder.Frame(owner, headroom, dataLen); + var text = Encoding.ASCII.GetString(framed.Span); + + Assert.Contains("5\r\nhello\r\n", text); + owner.Dispose(); + } + + [Fact(Timeout = 5000)] + public void GetTerminator_should_return_zero_chunk() + { + var encoder = new ChunkedFramingEncoder(maxChunkSize: 4 * 1024); + var terminator = encoder.GetTerminator(); + var text = Encoding.ASCII.GetString(terminator.Memory.Span); + + Assert.Equal("0\r\n\r\n", text); + terminator.Owner.Dispose(); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/CloseDelimitedFramingDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/CloseDelimitedFramingDecoderSpec.cs new file mode 100644 index 000000000..4897818f5 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/CloseDelimitedFramingDecoderSpec.cs @@ -0,0 +1,42 @@ +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class CloseDelimitedFramingDecoderSpec +{ + [Fact(Timeout = 5000)] + public void Decode_should_pass_all_bytes_through() + { + var decoder = new CloseDelimitedFramingDecoder(); + decoder.Reset(long.MaxValue); + + var result = decoder.Decode("hello"u8, out var consumed); + + Assert.Equal(5, consumed); + Assert.Equal("hello"u8.ToArray(), result.Body.ToArray()); + Assert.False(result.EndOfBody); + Assert.False(decoder.IsComplete); + } + + [Fact(Timeout = 5000)] + public void OnEof_should_mark_complete() + { + var decoder = new CloseDelimitedFramingDecoder(); + decoder.Reset(long.MaxValue); + decoder.Decode("data"u8, out _); + + Assert.True(decoder.OnEof()); + Assert.True(decoder.IsComplete); + } + + [Fact(Timeout = 5000)] + public void Decode_should_reject_body_exceeding_limit() + { + var decoder = new CloseDelimitedFramingDecoder(); + decoder.Reset(5); + + decoder.Decode("hello"u8, out _); + + Assert.Throws(() => decoder.Decode("x"u8, out _)); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/ConnectionBodyPoolSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/ConnectionBodyPoolSpec.cs new file mode 100644 index 000000000..0509432d3 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/ConnectionBodyPoolSpec.cs @@ -0,0 +1,117 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Semantics; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class ConnectionBodyPoolSpec +{ + private static readonly BodyDecoderOptions DecoderOptions = new() + { + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 64 * 1024, + MaxStreamedBodySize = 8 * 1024 * 1024, + MaxChunkExtensionLength = 256 + }; + + [Fact(Timeout = 5000)] + public void RentReader_should_return_buffered_reader_for_small_body() + { + using var pool = new ConnectionBodyPool(); + var classification = new BodyClassification(BodyFraming.Length, 100); + + var (reader, decoder) = pool.RentReader(classification, DecoderOptions); + + Assert.NotNull(reader); + Assert.True(reader.IsBuffered); + Assert.Null(decoder); + } + + [Fact(Timeout = 5000)] + public void RentReader_should_return_bridged_reader_for_large_body() + { + using var pool = new ConnectionBodyPool(); + var classification = new BodyClassification(BodyFraming.Length, 128 * 1024); + + var (reader, decoder) = pool.RentReader(classification, DecoderOptions); + + Assert.NotNull(reader); + Assert.False(reader.IsBuffered); + Assert.NotNull(decoder); + } + + [Fact(Timeout = 5000)] + public void RentReader_should_return_same_instance_on_reuse() + { + using var pool = new ConnectionBodyPool(); + var classification = new BodyClassification(BodyFraming.Length, 128 * 1024); + + var (reader1, _) = pool.RentReader(classification, DecoderOptions); + pool.ReturnReader(); + var (reader2, _) = pool.RentReader(classification, DecoderOptions); + + Assert.Same(reader1, reader2); + } + + [Fact(Timeout = 5000)] + public void RentReader_should_return_null_for_no_body() + { + using var pool = new ConnectionBodyPool(); + var classification = new BodyClassification(BodyFraming.None, null); + + var (reader, decoder) = pool.RentReader(classification, DecoderOptions); + + Assert.Null(reader); + Assert.Null(decoder); + } + + private static readonly BodyEncoderOptions EncoderOptions = new() { ChunkSize = 16 * 1024 }; + + private static ValueTask NoOpSend(IMemoryOwner owner, ReadOnlyMemory data) + { + owner.Dispose(); + return default; + } + + [Fact(Timeout = 5000)] + public void RentWriter_should_return_streaming_for_http10_with_known_length() + { + using var pool = new ConnectionBodyPool(); + + var (writer, encoder) = pool.RentWriter( + hasBody: true, contentLength: 256, System.Net.HttpVersion.Version10, + EncoderOptions, NoOpSend); + + Assert.NotNull(writer); + Assert.IsType(writer); + Assert.NotNull(encoder); + Assert.IsType(encoder); + } + + [Fact(Timeout = 5000)] + public void RentWriter_should_return_buffered_for_http10_with_unknown_length() + { + using var pool = new ConnectionBodyPool(); + + var (writer, encoder) = pool.RentWriter( + hasBody: true, contentLength: null, System.Net.HttpVersion.Version10, + EncoderOptions, NoOpSend, onBufferedComplete: (_, _) => { }); + + Assert.NotNull(writer); + Assert.IsType(writer); + Assert.Null(encoder); + } + + [Fact(Timeout = 5000)] + public void RentWriter_should_return_null_for_no_body() + { + using var pool = new ConnectionBodyPool(); + + var (writer, encoder) = pool.RentWriter( + hasBody: false, contentLength: null, System.Net.HttpVersion.Version11, + EncoderOptions, NoOpSend); + + Assert.Null(writer); + Assert.Null(encoder); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/ContentLengthFramingDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/ContentLengthFramingDecoderSpec.cs new file mode 100644 index 000000000..604a5a343 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/ContentLengthFramingDecoderSpec.cs @@ -0,0 +1,74 @@ +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class ContentLengthFramingDecoderSpec +{ + [Fact(Timeout = 5000)] + public void Decode_should_return_exact_bytes_when_data_matches_remaining() + { + var decoder = new ContentLengthFramingDecoder(); + decoder.Reset(5); + + var result = decoder.Decode("hello"u8, out var consumed); + + Assert.Equal(5, consumed); + Assert.Equal("hello"u8.ToArray(), result.Body.ToArray()); + Assert.True(result.EndOfBody); + Assert.True(decoder.IsComplete); + } + + [Fact(Timeout = 5000)] + public void Decode_should_consume_only_remaining_bytes_when_excess_data() + { + var decoder = new ContentLengthFramingDecoder(); + decoder.Reset(3); + + var result = decoder.Decode("helloextra"u8, out var consumed); + + Assert.Equal(3, consumed); + Assert.Equal("hel"u8.ToArray(), result.Body.ToArray()); + Assert.True(result.EndOfBody); + } + + [Fact(Timeout = 5000)] + public void Decode_should_track_remaining_across_calls() + { + var decoder = new ContentLengthFramingDecoder(); + decoder.Reset(5); + + var r1 = decoder.Decode("he"u8, out var c1); + Assert.Equal(2, c1); + Assert.False(r1.EndOfBody); + + var r2 = decoder.Decode("llo"u8, out var c2); + Assert.Equal(3, c2); + Assert.True(r2.EndOfBody); + } + + [Fact(Timeout = 5000)] + public void Reset_should_allow_reuse() + { + var decoder = new ContentLengthFramingDecoder(); + decoder.Reset(2); + decoder.Decode("ab"u8, out _); + Assert.True(decoder.IsComplete); + + decoder.Reset(3); + Assert.False(decoder.IsComplete); + decoder.Decode("xyz"u8, out _); + Assert.True(decoder.IsComplete); + } + + [Fact(Timeout = 5000)] + public void Drain_should_discard_bytes_without_returning_body() + { + var decoder = new ContentLengthFramingDecoder(); + decoder.Reset(10); + + Assert.Equal(5, decoder.Drain("hello"u8)); + Assert.False(decoder.IsComplete); + Assert.Equal(5, decoder.Drain("world"u8)); + Assert.True(decoder.IsComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/FramingDecoderQueuedReaderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/FramingDecoderQueuedReaderSpec.cs new file mode 100644 index 000000000..237d32e9c --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/FramingDecoderQueuedReaderSpec.cs @@ -0,0 +1,78 @@ +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class FramingDecoderQueuedReaderSpec +{ + [Fact(Timeout = 5000)] + public async Task ContentLength_decoder_should_enqueue_body_into_queued_reader() + { + var framing = new ContentLengthFramingDecoder(); + framing.Reset(5); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + + var input = "hello"u8.ToArray().AsSpan(); + var result = framing.Decode(input, out var consumed); + Assert.Equal(5, consumed); + Assert.True(result.EndOfBody); + Assert.True(reader.TryEnqueue(result.Body)); + reader.Complete(); + + var readResult = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("hello"u8.ToArray(), readResult.Memory.ToArray()); + reader.AdvanceTo(); + + var endResult = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.True(endResult.IsCompleted); + } + + [Fact(Timeout = 5000)] + public async Task Chunked_decoder_should_enqueue_body_chunks() + { + var framing = new ChunkedFramingDecoder(); + framing.Reset(1 * 1024 * 1024, 256); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + + var chunk = "5\r\nhello\r\n"u8.ToArray().AsSpan(); + var result = framing.Decode(chunk, out _); + Assert.False(result.EndOfBody); + Assert.True(reader.TryEnqueue(result.Body)); + + var readResult = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("hello"u8.ToArray(), readResult.Memory.ToArray()); + reader.AdvanceTo(); + + var terminator = "0\r\n\r\n"u8.ToArray().AsSpan(); + var result2 = framing.Decode(terminator, out _); + Assert.True(result2.EndOfBody); + reader.Complete(); + + var endResult = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.True(endResult.IsCompleted); + } + + [Fact(Timeout = 5000)] + public async Task CloseDelimited_decoder_should_enqueue_and_complete_on_eof() + { + var framing = new CloseDelimitedFramingDecoder(); + framing.Reset(1 * 1024 * 1024); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + + var data = "some data"u8.ToArray().AsSpan(); + var result = framing.Decode(data, out _); + Assert.True(reader.TryEnqueue(result.Body)); + + Assert.True(framing.OnEof()); + reader.Complete(); + + var readResult = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("some data"u8.ToArray(), readResult.Memory.ToArray()); + reader.AdvanceTo(); + + var endResult = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.True(endResult.IsCompleted); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/PassthroughFramingEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/PassthroughFramingEncoderSpec.cs new file mode 100644 index 000000000..369c111ce --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/PassthroughFramingEncoderSpec.cs @@ -0,0 +1,36 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class PassthroughFramingEncoderSpec +{ + [Fact(Timeout = 5000)] + public void Frame_should_return_exact_data_slice() + { + var encoder = new PassthroughFramingEncoder(); + var owner = MemoryPool.Shared.Rent(16); + "hello"u8.CopyTo(owner.Memory.Span); + + var framed = encoder.Frame(owner, headroom: 0, dataLength: 5); + + Assert.Equal(5, framed.Length); + Assert.Equal("hello"u8.ToArray(), framed.ToArray()); + owner.Dispose(); + } + + [Fact(Timeout = 5000)] + public void Headroom_and_trailer_should_be_zero() + { + var encoder = new PassthroughFramingEncoder(); + Assert.Equal(0, encoder.Headroom); + Assert.Equal(0, encoder.Trailer); + } + + [Fact(Timeout = 5000)] + public void GetTerminator_should_return_empty() + { + var encoder = new PassthroughFramingEncoder(); + Assert.True(encoder.GetTerminator().IsEmpty); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyReaderConcurrencySpec.cs b/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyReaderConcurrencySpec.cs new file mode 100644 index 000000000..ef55b687b --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyReaderConcurrencySpec.cs @@ -0,0 +1,90 @@ +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +/// +/// QueuedBodyReader is fed from the connection-stage thread while the application +/// thread consumes the body stream — a genuine cross-thread boundary. These specs +/// hammer that boundary: any lost, duplicated, or reordered chunk breaks the +/// position-derived byte pattern or the total length. +/// +public sealed class QueuedBodyReaderConcurrencySpec +{ + private const int ChunkSize = 64; + private const int ChunkCount = 2000; + + private static byte[] BuildPattern() + { + var data = new byte[ChunkCount * ChunkSize]; + for (var p = 0; p < data.Length; p++) + { + data[p] = (byte)(p % 251); + } + + return data; + } + + private static async Task RunRoundAsync(bool throttleProducer, int round, CancellationToken ct) + { + var reader = new QueuedBodyReader(8); + var expected = BuildPattern(); + + var producer = Task.Run(() => + { + for (var i = 0; i < ChunkCount; i++) + { + reader.TryEnqueue(expected.AsSpan(i * ChunkSize, ChunkSize)); + if (throttleProducer) + { + // Keep the queue near-empty so the consumer's pending-read path + // (direct delivery) is exercised on almost every chunk. + Thread.SpinWait(200); + } + } + + reader.Complete(); + }, ct); + + using var ms = new MemoryStream(); + var stream = reader.AsStream(); + // Odd buffer size: forces partial chunk consumption between AdvanceTo calls. + var buffer = new byte[48]; + int read; + while ((read = await stream.ReadAsync(buffer, ct)) > 0) + { + ms.Write(buffer, 0, read); + } + + await producer; + + var actual = ms.ToArray(); + Assert.True(expected.Length == actual.Length, + $"round {round}: expected {expected.Length} bytes, got {actual.Length} (lost or duplicated chunks)"); + + for (var p = 0; p < actual.Length; p++) + { + if (actual[p] != expected[p]) + { + Assert.Fail($"round {round}: byte mismatch at position {p} (reordered or corrupted chunk)"); + } + } + } + + [Fact(Timeout = 30000)] + public async Task Concurrent_enqueue_with_slow_producer_should_preserve_order_and_completeness() + { + for (var round = 0; round < 10; round++) + { + await RunRoundAsync(throttleProducer: true, round, TestContext.Current.CancellationToken); + } + } + + [Fact(Timeout = 30000)] + public async Task Concurrent_enqueue_with_fast_producer_should_preserve_order_and_completeness() + { + for (var round = 0; round < 10; round++) + { + await RunRoundAsync(throttleProducer: false, round, TestContext.Current.CancellationToken); + } + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyReaderSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyReaderSpec.cs new file mode 100644 index 000000000..26ac815df --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyReaderSpec.cs @@ -0,0 +1,209 @@ +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class QueuedBodyReaderSpec +{ + [Fact(Timeout = 5000)] + public async Task ReadAsync_should_return_enqueued_data() + { + var reader = new QueuedBodyReader(4); + reader.TryEnqueue("hello"u8); + + var result = await reader.ReadAsync(TestContext.Current.CancellationToken); + + Assert.Equal("hello"u8.ToArray(), result.Memory.ToArray()); + Assert.False(result.IsCompleted); + } + + [Fact(Timeout = 5000)] + public async Task AdvanceTo_should_return_rental_and_allow_next_read() + { + var reader = new QueuedBodyReader(4); + + reader.TryEnqueue("first"u8); + var result1 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("first"u8.ToArray(), result1.Memory.ToArray()); + reader.AdvanceTo(); + + reader.TryEnqueue("second"u8); + var result2 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("second"u8.ToArray(), result2.Memory.ToArray()); + reader.AdvanceTo(); + } + + [Fact(Timeout = 5000)] + public async Task Complete_should_signal_end_of_body() + { + var reader = new QueuedBodyReader(4); + reader.Complete(); + + var result = await reader.ReadAsync(TestContext.Current.CancellationToken); + + Assert.True(result.IsCompleted); + Assert.True(result.Memory.IsEmpty); + } + + [Fact(Timeout = 5000)] + public async Task Fault_should_propagate_exception() + { + var reader = new QueuedBodyReader(4); + reader.Fault(new InvalidOperationException("test fault")); + + await Assert.ThrowsAsync( + () => reader.ReadAsync(TestContext.Current.CancellationToken).AsTask()); + } + + [Fact(Timeout = 5000)] + public async Task TryEnqueue_should_return_false_at_backpressure_threshold_but_still_store_data() + { + var reader = new QueuedBodyReader(2); + + Assert.True(reader.TryEnqueue("a"u8)); + Assert.False(reader.TryEnqueue("b"u8)); + Assert.True(reader.IsFull); + + var r1 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("a"u8.ToArray(), r1.Memory.ToArray()); + reader.AdvanceTo(); + + var r2 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("b"u8.ToArray(), r2.Memory.ToArray()); + reader.AdvanceTo(); + + reader.Dispose(); + } + + [Fact(Timeout = 5000)] + public async Task SlotFreed_should_fire_after_AdvanceTo() + { + var reader = new QueuedBodyReader(4); + var fired = false; + reader.SlotFreed += () => fired = true; + + reader.TryEnqueue("data"u8); + await reader.ReadAsync(TestContext.Current.CancellationToken); + reader.AdvanceTo(); + + Assert.True(fired); + } + + [Fact(Timeout = 5000)] + public async Task ReadAsync_should_wait_for_enqueue_when_empty() + { + var reader = new QueuedBodyReader(4); + + var readTask = reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.False(readTask.IsCompleted); + + reader.TryEnqueue("delayed"u8); + + var result = await readTask; + Assert.Equal("delayed"u8.ToArray(), result.Memory.ToArray()); + } + + [Fact(Timeout = 5000)] + public void IsBuffered_should_be_false() + { + var reader = new QueuedBodyReader(4); + Assert.False(reader.IsBuffered); + } + + [Fact(Timeout = 5000)] + public async Task Reset_should_drain_and_allow_reuse() + { + var reader = new QueuedBodyReader(4); + reader.TryEnqueue("old"u8); + reader.Complete(); + reader.Reset(); + + reader.TryEnqueue("new"u8); + var result = await reader.ReadAsync(TestContext.Current.CancellationToken); + + Assert.Equal("new"u8.ToArray(), result.Memory.ToArray()); + Assert.False(result.IsCompleted); + } + + [Fact(Timeout = 5000)] + public async Task Complete_after_enqueue_should_deliver_data_then_completion() + { + var reader = new QueuedBodyReader(4); + reader.TryEnqueue("data"u8); + reader.Complete(); + + var result1 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("data"u8.ToArray(), result1.Memory.ToArray()); + Assert.False(result1.IsCompleted); + reader.AdvanceTo(); + + var result2 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.True(result2.IsCompleted); + Assert.True(result2.Memory.IsEmpty); + } + + [Fact(Timeout = 5000)] + public async Task Multiple_chunks_should_be_readable_in_order() + { + var reader = new QueuedBodyReader(4); + reader.TryEnqueue("one"u8); + reader.TryEnqueue("two"u8); + reader.TryEnqueue("three"u8); + + var r1 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("one"u8.ToArray(), r1.Memory.ToArray()); + reader.AdvanceTo(); + + var r2 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("two"u8.ToArray(), r2.Memory.ToArray()); + reader.AdvanceTo(); + + var r3 = await reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal("three"u8.ToArray(), r3.Memory.ToArray()); + reader.AdvanceTo(); + } + + [Fact(Timeout = 5000)] + public async Task ReadAsync_should_throw_when_cancellation_token_fires() + { + var reader = new QueuedBodyReader(4); + reader.Reset(); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsAnyAsync(async () => + { + await reader.ReadAsync(cts.Token); + }); + } + + [Fact(Timeout = 5000)] + public async Task ReadAsync_should_throw_immediately_when_already_cancelled() + { + var reader = new QueuedBodyReader(4); + reader.Reset(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(async () => + { + await reader.ReadAsync(cts.Token); + }); + } + + [Fact(Timeout = 5000)] + public async Task ReadAsync_should_succeed_when_data_arrives_before_cancellation() + { + var reader = new QueuedBodyReader(4); + reader.Reset(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var readTask = reader.ReadAsync(cts.Token); + reader.TryEnqueue("hello"u8); + + var result = await readTask; + Assert.Equal("hello"u8.ToArray(), result.Memory.ToArray()); + Assert.False(result.IsCompleted); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Body/StreamingBodyWriterSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/StreamingBodyWriterSpec.cs new file mode 100644 index 000000000..b57bff9e5 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Body/StreamingBodyWriterSpec.cs @@ -0,0 +1,142 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; + +namespace TurboHTTP.Tests.Protocol.Body; + +public sealed class StreamingBodyWriterSpec +{ + [Fact(Timeout = 5000)] + public async Task FlushAsync_should_transfer_ownership_to_send_callback() + { + IMemoryOwner? receivedOwner = null; + ReadOnlyMemory receivedData = default; + + var encoder = new PassthroughFramingEncoder(); + var writer = new StreamingBodyWriter(); + writer.Reset(encoder, (owner, data) => + { + receivedOwner = owner; + receivedData = data; + return default; + }); + + var mem = writer.GetMemory(4); + "test"u8.CopyTo(mem.Span); + writer.Advance(4); + await writer.FlushAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(receivedOwner); + Assert.Equal(4, receivedData.Length); + Assert.Equal("test"u8.ToArray(), receivedData.ToArray()); + + receivedOwner.Dispose(); + } + + [Fact(Timeout = 5000)] + public async Task FlushAsync_should_not_dispose_rental_after_send() + { + IMemoryOwner? receivedOwner = null; + + var encoder = new PassthroughFramingEncoder(); + var writer = new StreamingBodyWriter(); + writer.Reset(encoder, (owner, data) => + { + receivedOwner = owner; + return default; + }); + + var mem = writer.GetMemory(4); + "data"u8.CopyTo(mem.Span); + writer.Advance(4); + await writer.FlushAsync(TestContext.Current.CancellationToken); + + // Writer should NOT have disposed — caller owns it + Assert.Equal((byte)'d', receivedOwner!.Memory.Span[0]); + + receivedOwner.Dispose(); + } + + [Fact(Timeout = 5000)] + public async Task CompleteAsync_should_send_terminator_with_ownership() + { + IMemoryOwner? terminatorOwner = null; + + var encoder = new ChunkedFramingEncoder(16 * 1024); + var writer = new StreamingBodyWriter(); + writer.Reset(encoder, (owner, data) => + { + terminatorOwner = owner; + return default; + }); + + await writer.CompleteAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(terminatorOwner); + terminatorOwner.Dispose(); + } + + [Fact(Timeout = 5000)] + public async Task FlushAsync_should_return_completed_false() + { + var encoder = new PassthroughFramingEncoder(); + var writer = new StreamingBodyWriter(); + writer.Reset(encoder, (owner, data) => + { + owner.Dispose(); + return default; + }); + + var mem = writer.GetMemory(2); + "ab"u8.CopyTo(mem.Span); + writer.Advance(2); + var result = await writer.FlushAsync(TestContext.Current.CancellationToken); + + Assert.False(result.IsCompleted); + } + + [Fact(Timeout = 5000)] + public async Task GetMemory_should_include_headroom_for_chunked_framing() + { + ReadOnlyMemory sentData = default; + + var encoder = new ChunkedFramingEncoder(4 * 1024); + var writer = new StreamingBodyWriter(); + writer.Reset(encoder, (owner, data) => + { + sentData = data; + owner.Dispose(); + return default; + }); + + var mem = writer.GetMemory(3); + Assert.True(mem.Length >= 3); + "abc"u8.CopyTo(mem.Span); + writer.Advance(3); + await writer.FlushAsync(TestContext.Current.CancellationToken); + + var text = System.Text.Encoding.ASCII.GetString(sentData.Span); + Assert.Contains("3\r\nabc\r\n", text); + } + + [Fact(Timeout = 5000)] + public async Task Reset_should_allow_reuse() + { + var callCount = 0; + var encoder = new PassthroughFramingEncoder(); + using var writer = new StreamingBodyWriter(); + + writer.Reset(encoder, (owner, _) => { callCount++; owner.Dispose(); return default; }); + var m1 = writer.GetMemory(2); + "ab"u8.CopyTo(m1.Span); + writer.Advance(2); + await writer.FlushAsync(TestContext.Current.CancellationToken); + + writer.Reset(encoder, (owner, _) => { callCount++; owner.Dispose(); return default; }); + var m2 = writer.GetMemory(2); + "cd"u8.CopyTo(m2.Span); + writer.Advance(2); + await writer.FlushAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, callCount); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/DataRateMonitorSpec.cs b/src/TurboHTTP.Tests/Protocol/DataRateMonitorSpec.cs new file mode 100644 index 000000000..6add40c74 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/DataRateMonitorSpec.cs @@ -0,0 +1,65 @@ +using TurboHTTP.Protocol; + +namespace TurboHTTP.Tests.Protocol; + +public sealed class DataRateMonitorSpec +{ + private const long Sec = 1000; + + [Fact(Timeout = 5000)] + public void Disabled_when_rate_not_positive() + { + var m = new DataRateMonitor(minDataRate: 0, gracePeriod: TimeSpan.FromSeconds(5)); + Assert.False(m.Enabled); + } + + [Fact(Timeout = 5000)] + public void Fast_transfer_should_not_violate() + { + var m = new DataRateMonitor(minDataRate: 100, gracePeriod: TimeSpan.FromSeconds(5)); + m.Observe(streamId: 1, bytes: 1000, now: 0); + m.Observe(streamId: 1, bytes: 1000, now: Sec); + var violations = new List(); + m.Check(now: Sec, violations); + Assert.Empty(violations); + } + + [Fact(Timeout = 5000)] + public void Slow_transfer_should_violate_after_grace() + { + var m = new DataRateMonitor(minDataRate: 100, gracePeriod: TimeSpan.FromSeconds(2)); + m.Observe(1, bytes: 10, now: 0); + + var v = new List(); + m.Check(now: 1 * Sec, v); Assert.Empty(v); + m.Check(now: 2 * Sec, v); Assert.Empty(v); + m.Check(now: 4 * Sec, v); Assert.Contains(1L, v); + } + + [Fact(Timeout = 5000)] + public void Streams_should_be_independent() + { + var m = new DataRateMonitor(minDataRate: 100, gracePeriod: TimeSpan.FromSeconds(1)); + m.Observe(1, 10, now: 0); + m.Observe(2, 10_000, now: 0); + m.Observe(2, 10_000, now: Sec); + + var v = new List(); + m.Check(now: 1 * Sec, v); + m.Check(now: 3 * Sec, v); + Assert.Contains(1L, v); + Assert.DoesNotContain(2L, v); + } + + [Fact(Timeout = 5000)] + public void Remove_should_stop_tracking() + { + var m = new DataRateMonitor(minDataRate: 100, gracePeriod: TimeSpan.FromSeconds(1)); + m.Observe(1, 10, now: 0); + m.Remove(1); + var v = new List(); + m.Check(now: 5 * Sec, v); + Assert.Empty(v); + Assert.Equal(0, m.Count); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/BodyDecoderFactorySpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/BodyDecoderFactorySpec.cs deleted file mode 100644 index 4b888a448..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/BodyDecoderFactorySpec.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Buffers; -using TurboHTTP.Protocol.LineBased.Body; -using TurboHTTP.Protocol.Semantics; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class BodyDecoderFactorySpec -{ - private const int Threshold = 1024; - - private static IBodyDecoder Create(BodyClassification c) - => BodyDecoderFactory.Create(c, Threshold, MemoryPool.Shared); - - [Theory(Timeout = 5000)] - [InlineData(0)] - [InlineData(1)] - [InlineData(1023)] - [InlineData(1024)] - public void Factory_should_return_Buffered_when_length_at_or_below_threshold(int len) - { - var decoder = Create(new BodyClassification(BodyFraming.Length, len)); - Assert.IsType(decoder); - decoder.Dispose(); - } - - [Theory(Timeout = 5000)] - [InlineData(1025)] - [InlineData(1_000_000)] - public void Factory_should_return_Streamed_when_length_above_threshold(int len) - { - var decoder = Create(new BodyClassification(BodyFraming.Length, len)); - Assert.IsType(decoder); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Factory_should_return_ChunkedDecoder_when_framing_is_Chunked() - { - var decoder = Create(new BodyClassification(BodyFraming.Chunked, null)); - Assert.IsType(decoder); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Factory_should_return_CloseDelimited_when_framing_is_Close() - { - var decoder = Create(new BodyClassification(BodyFraming.Close, null)); - Assert.IsType(decoder); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Factory_should_return_empty_Buffered_when_framing_is_None() - { - var decoder = Create(new BodyClassification(BodyFraming.None, null)); - Assert.IsType(decoder); - decoder.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/BodyEncoderFactorySpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/BodyEncoderFactorySpec.cs deleted file mode 100644 index 190df01ff..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/BodyEncoderFactorySpec.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Net; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class BodyEncoderFactorySpec -{ - private sealed class NonSeekableStream : Stream - { - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) => 0; - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - } - - [Fact(Timeout = 5000)] - public void Create_should_return_null_for_null_content() - { - var encoder = BodyEncoderFactory.Create(null, contentLength: null, HttpVersion.Version11); - Assert.Null(encoder); - } - - [Fact(Timeout = 5000)] - public void Create_should_return_streamed_for_http11_known_length() - { - var content = new ByteArrayContent(new byte[100]); - var contentLength = content.Headers.ContentLength; - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength, HttpVersion.Version11); - Assert.IsType(encoder); - encoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Create_should_return_chunked_and_set_header_for_http11_unknown_length() - { - var content = new StreamContent(new NonSeekableStream()); - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength: null, HttpVersion.Version11); - - Assert.IsType(encoder); - encoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Create_should_return_buffered_for_http10_known_length() - { - var content = new ByteArrayContent(new byte[200_000]); - var contentLength = content.Headers.ContentLength; - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength, HttpVersion.Version10); - Assert.IsType(encoder); - encoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Create_should_return_buffered_for_http10_unknown_length() - { - var content = new StreamContent(new MemoryStream(new byte[100])); - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength: null, HttpVersion.Version10); - Assert.IsType(encoder); - encoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Create_stream_with_content_length_should_return_content_length_encoder() - { - var stream = new MemoryStream("hello"u8.ToArray()); - var encoder = BodyEncoderFactory.Create(stream, contentLength: 5, HttpVersion.Version11); - Assert.IsType(encoder); - encoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Create_stream_without_content_length_should_return_chunked_encoder() - { - var stream = new MemoryStream("hello"u8.ToArray()); - var encoder = BodyEncoderFactory.Create(stream, contentLength: null, HttpVersion.Version11); - Assert.IsType(encoder); - encoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Create_null_stream_should_return_null() - { - var encoder = BodyEncoderFactory.Create(null, contentLength: null, HttpVersion.Version11); - Assert.Null(encoder); - } - - [Fact(Timeout = 5000)] - public void Create_stream_http10_should_return_buffered_encoder() - { - var stream = new MemoryStream("hello"u8.ToArray()); - var encoder = BodyEncoderFactory.Create(stream, contentLength: 5, HttpVersion.Version10); - Assert.IsType(encoder); - encoder.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ChunkedBodyDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/ChunkedBodyDecoderSpec.cs deleted file mode 100644 index 33b1414b3..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ChunkedBodyDecoderSpec.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Text; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class ChunkedBodyDecoderSpec -{ - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9112-7.1")] - public async Task Decoder_should_decode_two_chunks_and_terminator() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"u8.ToArray(); - Assert.True(decoder.Feed(data, out _)); - - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("hello world", Encoding.ASCII.GetString(bytes)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9112-7.1.1")] - public async Task Decoder_should_ignore_chunk_extensions() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5;ext=foo\r\nhello\r\n0\r\n\r\n"u8.ToArray(); - Assert.True(decoder.Feed(data, out _)); - - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("hello", Encoding.ASCII.GetString(bytes)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9112-7.1")] - public void Decoder_should_signal_NeedMore_when_chunk_incomplete() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5\r\nhel"u8.ToArray(); - Assert.False(decoder.Feed(data, out _)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Decoder_should_reject_invalid_chunk_size() - { - var decoder = new ChunkedBodyDecoder(); - var data = "XYZ\r\n"u8.ToArray(); - Assert.Throws(() => decoder.Feed(data, out _)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9110-6.5")] - public async Task Decoder_should_accept_allowed_trailer_fields() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5\r\nhello\r\n0\r\nX-Custom-Trailer: value\r\n\r\n"u8.ToArray(); - Assert.True(decoder.Feed(data, out _)); - - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("hello", Encoding.ASCII.GetString(bytes)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9110-6.5")] - public async Task Decoder_should_skip_prohibited_trailer_fields() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5\r\nhello\r\n0\r\nTransfer-Encoding: chunked\r\nX-Custom: ok\r\n\r\n"u8.ToArray(); - Assert.True(decoder.Feed(data, out _)); - - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("hello", Encoding.ASCII.GetString(bytes)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9110-6.5")] - public void Decoder_should_collect_allowed_trailer_fields() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5\r\nhello\r\n0\r\nX-Checksum: abc123\r\nServer-Timing: dur=42\r\n\r\n"u8.ToArray(); - Assert.True(decoder.Feed(data, out _)); - - Assert.Equal(2, decoder.Trailers.Count); - Assert.Equal("abc123", decoder.Trailers[0].Value); - Assert.Equal("dur=42", decoder.Trailers[1].Value); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9110-6.5")] - public void Decoder_should_filter_prohibited_trailer_fields() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5\r\nhello\r\n0\r\nX-Custom: ok\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n"u8.ToArray(); - Assert.True(decoder.Feed(data, out _)); - - Assert.Single(decoder.Trailers); - Assert.Equal("ok", decoder.Trailers[0].Value); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9110-6.5")] - public void Decoder_should_have_empty_trailers_when_none_present() - { - var decoder = new ChunkedBodyDecoder(); - var data = "5\r\nhello\r\n0\r\n\r\n"u8.ToArray(); - Assert.True(decoder.Feed(data, out _)); - - Assert.Empty(decoder.Trailers); - decoder.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ChunkedBodyEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/ChunkedBodyEncoderSpec.cs deleted file mode 100644 index 515ba6858..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ChunkedBodyEncoderSpec.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Text; -using Akka.TestKit.Xunit; -using TurboHTTP.Protocol; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class ChunkedBodyEncoderSpec : TestKit -{ - [Fact(Timeout = 5000)] - public void Start_should_wrap_body_in_chunk_framing() - { - var probe = CreateTestProbe(); - var content = new ByteArrayContent("hello"u8.ToArray()); - using var encoder = new ChunkedBodyEncoder(chunkSize: 16_384); - - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, probe.Ref); - - var chunks = new List(); - while (true) - { - var msg = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - if (msg is OutboundBodyComplete) break; - chunks.Add(Assert.IsType(msg)); - } - - var all = string.Concat(chunks.Select(c => - { - var s = Encoding.ASCII.GetString(c.Owner.Memory.Span[..c.Length]); - c.Owner.Dispose(); - return s; - })); - - Assert.Contains("5\r\nhello\r\n", all); - Assert.Contains("0\r\n\r\n", all); - } - - [Fact(Timeout = 5000)] - public void Start_should_emit_terminator_only_for_empty_body() - { - var probe = CreateTestProbe(); - var content = new ByteArrayContent([]); - using var encoder = new ChunkedBodyEncoder(chunkSize: 16_384); - - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, probe.Ref); - - var msg = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var chunk = Assert.IsType(msg); - var wire = Encoding.ASCII.GetString(chunk.Owner.Memory.Span[..chunk.Length]); - Assert.Equal("0\r\n\r\n", wire); - chunk.Owner.Dispose(); - - var msg2 = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - Assert.IsType(msg2); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/CloseDelimitedBodyDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/CloseDelimitedBodyDecoderSpec.cs deleted file mode 100644 index 3a5bf875e..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/CloseDelimitedBodyDecoderSpec.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class CloseDelimitedBodyDecoderSpec -{ - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9112-6.3")] - public async Task Decoder_should_accumulate_until_eof() - { - var decoder = new CloseDelimitedBodyDecoder(); - Assert.False(decoder.Feed("part1"u8, out var c1)); - Assert.Equal(5, c1); - Assert.False(decoder.Feed("part2"u8, out var c2)); - Assert.Equal(5, c2); - - Assert.True(decoder.OnEof()); - - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("part1part2", Encoding.ASCII.GetString(bytes)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Feed_should_never_return_true() - { - var decoder = new CloseDelimitedBodyDecoder(); - Assert.False(decoder.Feed("data"u8, out _)); - decoder.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoderSpec.cs deleted file mode 100644 index fe47d4493..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoderSpec.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Text; -using Akka.TestKit.Xunit; -using TurboHTTP.Protocol; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class ContentLengthBufferedBodyEncoderSpec : TestKit -{ - [Fact(Timeout = 5000)] - public void Start_should_deliver_body_chunk_then_complete() - { - var probe = CreateTestProbe(); - var content = new ByteArrayContent("hello"u8.ToArray()); - using var encoder = new ContentLengthBufferedBodyEncoder(); - - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, probe.Ref); - - var msg1 = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var chunk = Assert.IsType(msg1); - Assert.Equal(5, chunk.Length); - Assert.Equal("hello", Encoding.UTF8.GetString(chunk.Owner.Memory.Span[..chunk.Length])); - chunk.Owner.Dispose(); - - var msg2 = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - Assert.IsType(msg2); - } - - [Fact(Timeout = 5000)] - public void Start_should_deliver_failed_on_error() - { - var probe = CreateTestProbe(); - using var encoder = new ContentLengthBufferedBodyEncoder(); - - var bodyStream = new FailingStream(); - encoder.Start(bodyStream, probe.Ref); - - var msg = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var failed = Assert.IsType(msg); - Assert.NotNull(failed.Reason); - } - - private sealed class FailingStream : Stream - { - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - - public override void Flush() { } - - public override int Read(byte[] buffer, int offset, int count) - => throw new InvalidOperationException("content error"); - - public override long Seek(long offset, SeekOrigin origin) - => throw new NotSupportedException(); - - public override void SetLength(long value) - => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthBufferedDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthBufferedDecoderSpec.cs deleted file mode 100644 index 49ccbde30..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthBufferedDecoderSpec.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Buffers; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class ContentLengthBufferedDecoderSpec -{ - [Fact(Timeout = 5000)] - public async Task Decoder_should_complete_when_all_bytes_received_in_one_feed() - { - var decoder = new ContentLengthBufferedDecoder(5, MemoryPool.Shared); - var done = decoder.Feed("hello"u8, out var consumed); - - Assert.True(done); - Assert.Equal(5, consumed); - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal(5, bytes.Length); - Assert.Equal((byte)'h', bytes[0]); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Decoder_should_accumulate_across_feeds() - { - var decoder = new ContentLengthBufferedDecoder(5, MemoryPool.Shared); - Assert.False(decoder.Feed("he"u8, out var c1)); - Assert.Equal(2, c1); - Assert.True(decoder.Feed("llo!extra"u8, out var c2)); - Assert.Equal(3, c2); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Decoder_should_handle_zero_length_body() - { - var decoder = new ContentLengthBufferedDecoder(0, MemoryPool.Shared); - Assert.True(decoder.Feed(ReadOnlySpan.Empty, out var consumed)); - Assert.Equal(0, consumed); - var bodyStream = decoder.GetBodyStream(); - Assert.NotNull(bodyStream); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public async Task Decoder_should_return_correct_bytes() - { - var decoder = new ContentLengthBufferedDecoder(3, MemoryPool.Shared); - decoder.Feed("ab"u8, out _); - decoder.Feed("cdef"u8, out _); - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("abc"u8.ToArray(), bytes); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void OnEof_should_return_false_when_incomplete() - { - var decoder = new ContentLengthBufferedDecoder(10, MemoryPool.Shared); - decoder.Feed("short"u8, out _); - Assert.False(decoder.OnEof()); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void OnEof_should_return_true_when_complete() - { - var decoder = new ContentLengthBufferedDecoder(5, MemoryPool.Shared); - decoder.Feed("hello"u8, out _); - Assert.True(decoder.OnEof()); - decoder.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoderSpec.cs deleted file mode 100644 index 0a7bf47fe..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoderSpec.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Text; -using Akka.TestKit.Xunit; -using TurboHTTP.Protocol; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class ContentLengthStreamedBodyEncoderSpec : TestKit -{ - [Fact(Timeout = 5000)] - public void Start_should_deliver_chunks_then_complete_for_small_body() - { - var probe = CreateTestProbe(); - var body = "small body"u8.ToArray(); - var content = new ByteArrayContent(body); - using var encoder = new ContentLengthStreamedBodyEncoder(chunkSize: 16_384); - - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, probe.Ref); - - var received = new List(); - while (true) - { - var msg = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - if (msg is OutboundBodyComplete) break; - var chunk = Assert.IsType(msg); - received.AddRange(chunk.Owner.Memory.Span[..chunk.Length].ToArray()); - chunk.Owner.Dispose(); - } - - Assert.Equal("small body", Encoding.UTF8.GetString(received.ToArray())); - } - - [Fact(Timeout = 5000)] - public void Start_should_split_body_larger_than_chunk_size() - { - var probe = CreateTestProbe(); - var body = new byte[1000]; - Random.Shared.NextBytes(body); - var content = new ByteArrayContent(body); - using var encoder = new ContentLengthStreamedBodyEncoder(chunkSize: 400); - - var bodyStream = content.ReadAsStream(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, probe.Ref); - - var chunks = new List(); - while (true) - { - var msg = probe.ReceiveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - if (msg is OutboundBodyComplete) break; - chunks.Add(Assert.IsType(msg)); - } - - Assert.True(chunks.Count >= 2); - var all = chunks.SelectMany(c => - { - var arr = c.Owner.Memory.Span[..c.Length].ToArray(); - c.Owner.Dispose(); - return arr; - }).ToArray(); - Assert.Equal(body, all); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthStreamedDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthStreamedDecoderSpec.cs deleted file mode 100644 index b014c4e92..000000000 --- a/src/TurboHTTP.Tests/Protocol/LineBased/Body/ContentLengthStreamedDecoderSpec.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text; -using TurboHTTP.Protocol.LineBased.Body; - -namespace TurboHTTP.Tests.Protocol.LineBased.Body; - -public sealed class ContentLengthStreamedDecoderSpec -{ - [Fact(Timeout = 5000)] - public async Task Decoder_should_stream_bytes_through_pipe() - { - var decoder = new ContentLengthStreamedDecoder(11); - Assert.False(decoder.Feed("hello "u8, out var c1)); - Assert.Equal(6, c1); - Assert.True(decoder.Feed("world"u8, out var c2)); - Assert.Equal(5, c2); - - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var bytes = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("hello world", Encoding.ASCII.GetString(bytes)); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void Decoder_should_consume_only_needed_bytes() - { - var decoder = new ContentLengthStreamedDecoder(3); - Assert.True(decoder.Feed("abcdef"u8, out var consumed)); - Assert.Equal(3, consumed); - decoder.Dispose(); - } - - [Fact(Timeout = 5000)] - public void OnEof_should_return_false_when_incomplete() - { - var decoder = new ContentLengthStreamedDecoder(10); - decoder.Feed("short"u8, out _); - Assert.False(decoder.OnEof()); - decoder.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/BufferedBodyDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/BufferedBodyDecoderSpec.cs deleted file mode 100644 index 34630ae3a..000000000 --- a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/BufferedBodyDecoderSpec.cs +++ /dev/null @@ -1,44 +0,0 @@ -using TurboHTTP.Protocol.Multiplexed.Body; - -namespace TurboHTTP.Tests.Protocol.Multiplexed.Body; - -public sealed class BufferedBodyDecoderSpec -{ - [Fact(Timeout = 5000)] - public async Task BufferedBodyDecoder_should_accumulate_data_and_produce_content() - { - using var decoder = new BufferedBodyDecoder(); - decoder.Feed("Hello, "u8, endStream: false); - decoder.Feed("World!"u8, endStream: true); - - Assert.True(decoder.IsComplete); - var bodyStream = decoder.GetBodyStream(); - var bytes = await new StreamContent(bodyStream).ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("Hello, World!"u8.ToArray(), bytes); - } - - [Fact(Timeout = 5000)] - public async Task BufferedBodyDecoder_should_handle_empty_body() - { - using var decoder = new BufferedBodyDecoder(); - decoder.Feed(ReadOnlySpan.Empty, endStream: true); - Assert.True(decoder.IsComplete); - var bodyStream = decoder.GetBodyStream(); - var bytes = await new StreamContent(bodyStream).ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Empty(bytes); - } - - [Fact(Timeout = 5000)] - public async Task BufferedBodyDecoder_should_handle_single_large_chunk() - { - using var decoder = new BufferedBodyDecoder(); - var data = new byte[32_768]; - Random.Shared.NextBytes(data); - decoder.Feed(data, endStream: true); - Assert.True(decoder.IsComplete); - var bodyStream = decoder.GetBodyStream(); - var content = new StreamContent(bodyStream); - var result = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal(data, result); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/BufferedBodyEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/BufferedBodyEncoderSpec.cs deleted file mode 100644 index 07fc54234..000000000 --- a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/BufferedBodyEncoderSpec.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Concurrent; -using TurboHTTP.Protocol; -using TurboHTTP.Protocol.Multiplexed.Body; - -namespace TurboHTTP.Tests.Protocol.Multiplexed.Body; - -public sealed class BufferedBodyEncoderSpec -{ - [Fact(Timeout = 5000)] - public async Task BufferedBodyEncoder_should_drain_content_as_single_chunk() - { - var messages = new BlockingCollection(); - var body = new byte[100]; - Random.Shared.NextBytes(body); - var content = new ByteArrayContent(body); - - using var encoder = new BufferedBodyEncoder(); - var bodyStream = await content.ReadAsStreamAsync(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, messages.Add); - - var chunk = (OutboundBodyChunk)messages.Take(TestContext.Current.CancellationToken); - Assert.Equal(100, chunk.Length); - Assert.Equal(body, chunk.Owner.Memory[..chunk.Length].ToArray()); - chunk.Owner.Dispose(); - - var complete = messages.Take(TestContext.Current.CancellationToken); - Assert.IsType(complete); - } - - [Fact(Timeout = 5000)] - public async Task BufferedBodyEncoder_should_handle_empty_content() - { - var messages = new BlockingCollection(); - var content = new ByteArrayContent([]); - - using var encoder = new BufferedBodyEncoder(); - var bodyStream = await content.ReadAsStreamAsync(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, messages.Add); - - var chunk = (OutboundBodyChunk)messages.Take(TestContext.Current.CancellationToken); - Assert.Equal(0, chunk.Length); - chunk.Owner.Dispose(); - - var complete = messages.Take(TestContext.Current.CancellationToken); - Assert.IsType(complete); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/StreamingBodyDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/StreamingBodyDecoderSpec.cs deleted file mode 100644 index 8a500f315..000000000 --- a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/StreamingBodyDecoderSpec.cs +++ /dev/null @@ -1,28 +0,0 @@ -using TurboHTTP.Protocol.Multiplexed.Body; - -namespace TurboHTTP.Tests.Protocol.Multiplexed.Body; - -public sealed class StreamingBodyDecoderSpec -{ - [Fact(Timeout = 5000)] - public async Task StreamingBodyDecoder_should_stream_data_through_content() - { - using var decoder = new StreamingBodyDecoder(); - decoder.Feed("Hello"u8, endStream: false); - decoder.Feed(" Stream"u8, endStream: true); - - Assert.True(decoder.IsComplete); - var bodyStream = decoder.GetBodyStream(); - var bytes = await new StreamContent(bodyStream).ReadAsByteArrayAsync(TestContext.Current.CancellationToken); - Assert.Equal("Hello Stream"u8.ToArray(), bytes); - } - - [Fact(Timeout = 5000)] - public void StreamingBodyDecoder_should_abort_cleanly() - { - using var decoder = new StreamingBodyDecoder(); - decoder.Feed("partial"u8, endStream: false); - decoder.Abort(); - Assert.False(decoder.IsComplete); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/StreamingBodyEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/StreamingBodyEncoderSpec.cs deleted file mode 100644 index 194681e4c..000000000 --- a/src/TurboHTTP.Tests/Protocol/Multiplexed/Body/StreamingBodyEncoderSpec.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Concurrent; -using TurboHTTP.Protocol; -using TurboHTTP.Protocol.Multiplexed.Body; - -namespace TurboHTTP.Tests.Protocol.Multiplexed.Body; - -public sealed class StreamingBodyEncoderSpec -{ - [Fact(Timeout = 5000)] - public async Task StreamingBodyEncoder_should_drain_content_in_chunks() - { - var messages = new BlockingCollection(); - var body = new byte[32_768]; - Random.Shared.NextBytes(body); - var content = new ByteArrayContent(body); - - using var encoder = new StreamingBodyEncoder(chunkSize: 16_384); - var bodyStream = await content.ReadAsStreamAsync(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, messages.Add); - - var totalReceived = 0; - while (true) - { - var msg = messages.Take(TestContext.Current.CancellationToken); - if (msg is OutboundBodyChunk chunk) - { - Assert.True(chunk.Length > 0); - Assert.True(chunk.Length <= 16_384); - totalReceived += chunk.Length; - chunk.Owner.Dispose(); - } - else if (msg is OutboundBodyComplete) - { - break; - } - } - - Assert.Equal(body.Length, totalReceived); - } - - [Fact(Timeout = 5000)] - public async Task StreamingBodyEncoder_should_complete_for_small_content() - { - var messages = new BlockingCollection(); - var body = new byte[100]; - Random.Shared.NextBytes(body); - var content = new ByteArrayContent(body); - - using var encoder = new StreamingBodyEncoder(); - var bodyStream = await content.ReadAsStreamAsync(TestContext.Current.CancellationToken); - encoder.Start(bodyStream, messages.Add); - - var chunk = (OutboundBodyChunk)messages.Take(TestContext.Current.CancellationToken); - Assert.Equal(100, chunk.Length); - chunk.Owner.Dispose(); - - var complete = messages.Take(TestContext.Current.CancellationToken); - Assert.IsType(complete); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs index de9c486f5..3adb59214 100644 --- a/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs @@ -4,33 +4,6 @@ namespace TurboHTTP.Tests.Protocol.Multiplexed; public sealed class FlowControllerSpec { - [Fact(Timeout = 5000)] - public void FlowController_should_return_min_of_connection_and_stream_window() - { - var fc = new FlowController( - connectionWindowSize: 65535, - streamWindowSize: 65535, - initialConnectionSendWindow: 1000, - initialStreamSendWindow: 500); - - fc.InitStreamSendWindow(1); - Assert.Equal(500, fc.GetSendWindow(1)); - } - - [Fact(Timeout = 5000)] - public void FlowController_should_decrement_both_windows_on_data_sent() - { - var fc = new FlowController( - connectionWindowSize: 65535, - streamWindowSize: 65535, - initialConnectionSendWindow: 1000, - initialStreamSendWindow: 1000); - - fc.InitStreamSendWindow(1); - fc.OnDataSent(1, 300); - Assert.Equal(700, fc.GetSendWindow(1)); - } - [Fact(Timeout = 5000)] public void FlowController_should_detect_connection_flow_control_violation() { @@ -59,7 +32,7 @@ public void FlowController_should_detect_stream_flow_control_violation() [Fact(Timeout = 5000)] public void FlowController_should_batch_window_updates() { - var windowSize = 65535; + const int windowSize = 65535; var fc = new FlowController( connectionWindowSize: windowSize, streamWindowSize: windowSize); @@ -75,21 +48,6 @@ public void FlowController_should_batch_window_updates() Assert.NotNull(result.StreamWindowUpdate); } - [Fact(Timeout = 5000)] - public void FlowController_should_increment_send_window_on_update() - { - var fc = new FlowController( - connectionWindowSize: 65535, - streamWindowSize: 65535, - initialConnectionSendWindow: 100, - initialStreamSendWindow: 100); - - fc.InitStreamSendWindow(1); - fc.OnSendWindowUpdate(0, 500); - fc.OnSendWindowUpdate(1, 500); - Assert.Equal(600, fc.GetSendWindow(1)); - } - [Fact(Timeout = 5000)] public void FlowController_should_track_goaway() { @@ -102,7 +60,7 @@ public void FlowController_should_track_goaway() [Fact(Timeout = 5000)] public void FlowController_should_return_pending_update_on_stream_close() { - var windowSize = 65535; + const int windowSize = 65535; var fc = new FlowController( connectionWindowSize: windowSize, streamWindowSize: windowSize); @@ -125,16 +83,88 @@ public void FlowController_should_reset_all_state() } [Fact(Timeout = 5000)] - public void FlowController_should_apply_initial_window_size_delta() + [Trait("RFC", "RFC9113-6.9.1")] + public void OnSendWindowUpdate_should_throw_when_connection_window_exceeds_max() { - var fc = new FlowController( - connectionWindowSize: 65535, - streamWindowSize: 65535, - initialConnectionSendWindow: 1000, - initialStreamSendWindow: 500); + var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535); + fc.OnSendWindowUpdate(0, int.MaxValue - 65535); + + Assert.Throws(() => + fc.OnSendWindowUpdate(0, 1)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9.1")] + public void OnSendWindowUpdate_should_throw_when_stream_window_exceeds_max() + { + var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535); + fc.OnSendWindowUpdate(1, int.MaxValue - 65535); + + Assert.Throws(() => + fc.OnSendWindowUpdate(1, 1)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9.1")] + public void OnSendWindowUpdate_should_allow_window_up_to_max() + { + var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535); + + var ex = Record.Exception(() => + fc.OnSendWindowUpdate(0, int.MaxValue - 65535)); + + Assert.Null(ex); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void OnDataSent_should_decrement_stream_send_window() + { + var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535); + + fc.OnDataSent(1, 10000); + + var window = fc.GetSendWindow(1); + Assert.Equal(65535 - 10000, window); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void OnDataSent_should_decrement_connection_window_across_all_streams() + { + var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535); + + fc.OnDataSent(1, 30000); + fc.OnDataSent(3, 20000); + + var freshStreamWindow = fc.GetSendWindow(99); + Assert.Equal(65535 - 50000, freshStreamWindow); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void GetSendWindow_should_return_min_of_connection_and_stream_window() + { + var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535); + + fc.OnDataSent(1, 60000); + + var window = fc.GetSendWindow(1); + Assert.Equal(65535 - 60000, window); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void OnDataSent_followed_by_window_update_should_restore_send_capacity() + { + var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535); + + fc.OnDataSent(1, 65535); + Assert.Equal(0, fc.GetSendWindow(1)); + + fc.OnSendWindowUpdate(0, 32768); + fc.OnSendWindowUpdate(1, 32768); - fc.InitStreamSendWindow(1); - fc.ApplyInitialWindowSizeDelta(200); - Assert.Equal(700, fc.GetSendWindow(1)); + Assert.Equal(32768, fc.GetSendWindow(1)); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/QuicStreamTrackerSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/QuicStreamTrackerSpec.cs index 4b5a8a2b5..f4190f96c 100644 --- a/src/TurboHTTP.Tests/Protocol/Multiplexed/QuicStreamTrackerSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Multiplexed/QuicStreamTrackerSpec.cs @@ -7,7 +7,7 @@ public sealed class QuicStreamTrackerSpec [Fact(Timeout = 5000)] public void QuicStreamTracker_should_allocate_stream_ids_starting_at_zero_with_increment_four() { - var tracker = new QuicStreamTracker(); + var tracker = new QuicStreamTracker(0, 100); Assert.Equal(0L, tracker.AllocateStreamId()); Assert.Equal(4L, tracker.AllocateStreamId()); Assert.Equal(8L, tracker.AllocateStreamId()); @@ -17,7 +17,7 @@ public void QuicStreamTracker_should_allocate_stream_ids_starting_at_zero_with_i [Fact(Timeout = 5000)] public void QuicStreamTracker_should_track_active_stream_count() { - var tracker = new QuicStreamTracker(); + var tracker = new QuicStreamTracker(0, 100); var id = tracker.AllocateStreamId(); tracker.OnStreamOpened(id); Assert.Equal(1, tracker.ActiveStreamCount); @@ -28,7 +28,7 @@ public void QuicStreamTracker_should_track_active_stream_count() [Fact(Timeout = 5000)] public void QuicStreamTracker_should_enforce_concurrency_limit() { - var tracker = new QuicStreamTracker(maxConcurrentStreams: 2); + var tracker = new QuicStreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 2); var id1 = tracker.AllocateStreamId(); tracker.OnStreamOpened(id1); var id2 = tracker.AllocateStreamId(); @@ -41,14 +41,14 @@ public void QuicStreamTracker_should_enforce_concurrency_limit() [Fact(Timeout = 5000)] public void QuicStreamTracker_should_return_false_when_closing_unknown_stream() { - var tracker = new QuicStreamTracker(); + var tracker = new QuicStreamTracker(0, 100); Assert.False(tracker.OnStreamClosed(999L)); } [Fact(Timeout = 5000)] public void QuicStreamTracker_should_reset_all_state() { - var tracker = new QuicStreamTracker(); + var tracker = new QuicStreamTracker(0, 100); var id = tracker.AllocateStreamId(); tracker.OnStreamOpened(id); tracker.Reset(); @@ -59,7 +59,7 @@ public void QuicStreamTracker_should_reset_all_state() [Fact(Timeout = 5000)] public void QuicStreamTracker_should_support_custom_initial_stream_id() { - var tracker = new QuicStreamTracker(initialNextStreamId: 100); + var tracker = new QuicStreamTracker(initialNextStreamId: 100, maxConcurrentStreams: 100); Assert.Equal(100L, tracker.AllocateStreamId()); Assert.Equal(104L, tracker.AllocateStreamId()); } diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/StreamTrackerSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/StreamTrackerSpec.cs index 502a11f9e..179987ebc 100644 --- a/src/TurboHTTP.Tests/Protocol/Multiplexed/StreamTrackerSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Multiplexed/StreamTrackerSpec.cs @@ -7,7 +7,7 @@ public sealed class StreamTrackerSpec [Fact(Timeout = 5000)] public void StreamTracker_should_allocate_odd_stream_ids_starting_at_one() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(1, 100); Assert.Equal(1, tracker.AllocateStreamId()); Assert.Equal(3, tracker.AllocateStreamId()); Assert.Equal(5, tracker.AllocateStreamId()); @@ -16,7 +16,7 @@ public void StreamTracker_should_allocate_odd_stream_ids_starting_at_one() [Fact(Timeout = 5000)] public void StreamTracker_should_track_active_stream_count() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(1, 100); var id = tracker.AllocateStreamId(); tracker.OnStreamOpened(id); Assert.Equal(1, tracker.ActiveStreamCount); @@ -27,7 +27,7 @@ public void StreamTracker_should_track_active_stream_count() [Fact(Timeout = 5000)] public void StreamTracker_should_enforce_concurrency_limit() { - var tracker = new StreamTracker(maxConcurrentStreams: 2); + var tracker = new StreamTracker(1, 2); var id1 = tracker.AllocateStreamId(); tracker.OnStreamOpened(id1); var id2 = tracker.AllocateStreamId(); @@ -40,18 +40,78 @@ public void StreamTracker_should_enforce_concurrency_limit() [Fact(Timeout = 5000)] public void StreamTracker_should_return_false_when_closing_unknown_stream() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(1, 100); Assert.False(tracker.OnStreamClosed(999)); } [Fact(Timeout = 5000)] public void StreamTracker_should_reset_all_state() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(1, 100); var id = tracker.AllocateStreamId(); tracker.OnStreamOpened(id); tracker.Reset(); Assert.Equal(0, tracker.ActiveStreamCount); Assert.Equal(1, tracker.AllocateStreamId()); } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1.1")] + public void TryAcceptClientStream_should_reject_stream_id_zero() + { + var tracker = new StreamTracker(1, 100); + var result = tracker.TryAcceptClientStream(0); + Assert.Equal(StreamAcceptResult.InvalidId, result); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1.1")] + public void TryAcceptClientStream_should_reject_even_stream_id() + { + var tracker = new StreamTracker(1, 100); + Assert.Equal(StreamAcceptResult.InvalidId, tracker.TryAcceptClientStream(2)); + Assert.Equal(StreamAcceptResult.InvalidId, tracker.TryAcceptClientStream(4)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1.1")] + public void TryAcceptClientStream_should_reject_non_monotonic_stream_id() + { + var tracker = new StreamTracker(1, 100); + Assert.Equal(StreamAcceptResult.Accepted, tracker.TryAcceptClientStream(3)); + Assert.Equal(StreamAcceptResult.NonMonotonic, tracker.TryAcceptClientStream(1)); + Assert.Equal(StreamAcceptResult.NonMonotonic, tracker.TryAcceptClientStream(3)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1.1")] + public void TryAcceptClientStream_should_reject_when_at_concurrency_limit() + { + var tracker = new StreamTracker(1, 1); + Assert.Equal(StreamAcceptResult.Accepted, tracker.TryAcceptClientStream(1)); + Assert.Equal(StreamAcceptResult.RefusedStream, tracker.TryAcceptClientStream(3)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1.1")] + public void TryAcceptClientStream_should_accept_valid_monotonic_odd_ids() + { + var tracker = new StreamTracker(1, 100); + Assert.Equal(StreamAcceptResult.Accepted, tracker.TryAcceptClientStream(1)); + Assert.Equal(StreamAcceptResult.Accepted, tracker.TryAcceptClientStream(3)); + Assert.Equal(StreamAcceptResult.Accepted, tracker.TryAcceptClientStream(5)); + Assert.Equal(3, tracker.ActiveStreamCount); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1.1")] + public void TryAcceptClientStream_should_track_highest_stream_id() + { + var tracker = new StreamTracker(1, 100); + Assert.Equal(0, tracker.HighestAcceptedStreamId); + tracker.TryAcceptClientStream(1); + Assert.Equal(1, tracker.HighestAcceptedStreamId); + tracker.TryAcceptClientStream(5); + Assert.Equal(5, tracker.HighestAcceptedStreamId); + } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/MultiplexedProtocolErrorSpec.cs b/src/TurboHTTP.Tests/Protocol/MultiplexedProtocolErrorSpec.cs new file mode 100644 index 000000000..8b0bbf1f6 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/MultiplexedProtocolErrorSpec.cs @@ -0,0 +1,38 @@ +using TurboHTTP.Protocol; + +namespace TurboHTTP.Tests.Protocol; + +public sealed class MultiplexedProtocolErrorSpec +{ + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.4.1")] + public void ConnectionProtocolException_should_carry_error_code() + { + var ex = new ConnectionProtocolException(0x9, "compression error"); + + Assert.Equal(0x9, ex.ErrorCode); + Assert.Equal("compression error", ex.Message); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.4.1")] + public void ConnectionProtocolException_should_be_an_HttpProtocolException() + { + // Derives from HttpProtocolException so existing catch/assert sites keep working while new + // code can catch the connection-scoped subtype to drive GOAWAY + teardown. + var ex = new ConnectionProtocolException(0x1, "protocol error"); + + Assert.IsAssignableFrom(ex); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.4.2")] + public void StreamProtocolException_should_carry_stream_id_and_error_code() + { + var ex = new StreamProtocolException(streamId: 3, errorCode: 0x1, "stream error"); + + Assert.Equal(3, ex.StreamId); + Assert.Equal(0x1, ex.ErrorCode); + Assert.IsAssignableFrom(ex); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/ProtocolNegotiatingStateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/ProtocolNegotiatingStateMachineSpec.cs index 40758f01d..628e4c82a 100644 --- a/src/TurboHTTP.Tests/Protocol/ProtocolNegotiatingStateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/ProtocolNegotiatingStateMachineSpec.cs @@ -32,7 +32,7 @@ private static TransportData MakeData(byte[] data) var buffer = TransportBuffer.Rent(data.Length); data.CopyTo(buffer.FullMemory.Span); buffer.Length = data.Length; - return new TransportData(buffer); + return TransportData.Rent(buffer); } // Task 2: ALPN Detection Tests diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs index 13e994b13..b80bdfd59 100644 --- a/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs @@ -220,6 +220,48 @@ public async Task AltSvcBidiStage_should_not_upgrade_if_already_http3() Assert.Equal(HttpVersion.Version30, result.Version); } + [Trait("RFC", "RFC7838")] + [Fact(Timeout = 5000)] + public async Task AltSvcBidiStage_should_not_upgrade_when_proxy_applies() + { + var cache = new AltSvcCache(); + cache.Store("example.com", [new AltSvcEntry("h3", "", 443, 86400, false, DateTimeOffset.UtcNow.AddHours(1))]); + + var stage = new AltSvcBidiStage(cache, useProxy: true, proxy: new WebProxy("http://proxy.local:8080")); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + { + Version = HttpVersion.Version11 + }; + + var results = await RunRequestAsync(stage, request); + + var result = Assert.Single(results); + Assert.Equal(HttpVersion.Version11, result.Version); + } + + [Trait("RFC", "RFC7838")] + [Fact(Timeout = 5000)] + public async Task AltSvcBidiStage_should_upgrade_when_proxy_bypasses_host() + { + var cache = new AltSvcCache(); + cache.Store("example.com", [new AltSvcEntry("h3", "", 443, 86400, false, DateTimeOffset.UtcNow.AddHours(1))]); + + var proxy = new WebProxy("http://proxy.local:8080") + { + BypassList = [@"example\.com"] + }; + var stage = new AltSvcBidiStage(cache, useProxy: true, proxy: proxy); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + { + Version = HttpVersion.Version11 + }; + + var results = await RunRequestAsync(stage, request); + + var result = Assert.Single(results); + Assert.Equal(HttpVersion.Version30, result.Version); + } + [Trait("RFC", "RFC7838")] [Fact(Timeout = 5000)] public async Task AltSvcBidiStage_should_handle_multiple_alt_svc_values() diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/Body/BodySemanticsClassifierSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/Body/BodySemanticsClassifierSpec.cs index 41b71e80e..e7e26f117 100644 --- a/src/TurboHTTP.Tests/Protocol/Semantics/Body/BodySemanticsClassifierSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Semantics/Body/BodySemanticsClassifierSpec.cs @@ -81,4 +81,109 @@ public void Classify_should_return_None_for_request_without_framing() new HeaderCollection(), HttpVersion.Version11); Assert.Equal(BodyFraming.None, r.Framing); } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void Classify_should_reject_request_when_final_transfer_coding_is_not_chunked() + { + // RFC 9112 §6.1: a request whose final transfer coding is not chunked has no reliable body + // length and MUST be rejected (400). Otherwise the body is parsed as the next request (smuggling). + Assert.Throws(() => + BodySemantics.ClassifyRequest(HttpMethod.Post, + Headers(("Transfer-Encoding", "gzip")), HttpVersion.Version11)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void Classify_should_reject_request_when_chunked_is_not_the_final_transfer_coding() + { + Assert.Throws(() => + BodySemantics.ClassifyRequest(HttpMethod.Post, + Headers(("Transfer-Encoding", "chunked, gzip")), HttpVersion.Version11)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void Classify_should_accept_request_when_chunked_is_the_final_transfer_coding() + { + var r = BodySemantics.ClassifyRequest(HttpMethod.Post, + Headers(("Transfer-Encoding", "gzip, chunked")), HttpVersion.Version11); + Assert.Equal(BodyFraming.Chunked, r.Framing); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void Classify_should_not_treat_substring_chunked_token_as_chunked() + { + Assert.Throws(() => + BodySemantics.ClassifyRequest(HttpMethod.Post, + Headers(("Transfer-Encoding", "x-chunked-ext")), HttpVersion.Version11)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void Classify_should_read_until_close_for_response_with_non_final_chunked() + { + // RFC 9112 §6.1: for a RESPONSE, a non-final chunked coding means read-until-close, not chunked. + var r = BodySemantics.ClassifyResponse(200, Headers(("Transfer-Encoding", "chunked, gzip")), + HttpVersion.Version11, false, connectionWillClose: true); + Assert.Equal(BodyFraming.Close, r.Framing); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.2")] + public void Classify_should_return_Length_for_http10_response_with_content_length() + { + var r = BodySemantics.ClassifyResponse(200, Headers(("Content-Length", "256")), + HttpVersion.Version10, false, connectionWillClose: true); + Assert.Equal(BodyFraming.Length, r.Framing); + Assert.Equal(256, r.ContentLength); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.3")] + public void Classify_should_return_Close_for_http10_response_without_content_length() + { + var r = BodySemantics.ClassifyResponse(200, new HeaderCollection(), + HttpVersion.Version10, false, connectionWillClose: true); + Assert.Equal(BodyFraming.Close, r.Framing); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.2")] + public void Classify_should_return_Length_for_http10_request_with_content_length() + { + var r = BodySemantics.ClassifyRequest(HttpMethod.Post, + Headers(("Content-Length", "512")), HttpVersion.Version10); + Assert.Equal(BodyFraming.Length, r.Framing); + Assert.Equal(512, r.ContentLength); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9110-6.4")] + public void Classify_should_return_None_for_http10_request_without_content_length() + { + var r = BodySemantics.ClassifyRequest(HttpMethod.Post, + new HeaderCollection(), HttpVersion.Version10); + Assert.Equal(BodyFraming.None, r.Framing); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9110-6.4")] + public void Classify_should_return_None_for_http10_response_to_HEAD() + { + var r = BodySemantics.ClassifyResponse(200, Headers(("Content-Length", "100")), + HttpVersion.Version10, requestMethodWasHead: true, connectionWillClose: true); + Assert.Equal(BodyFraming.None, r.Framing); + } + + [Theory(Timeout = 5000)] + [InlineData(100), InlineData(204), InlineData(304)] + [Trait("RFC", "RFC9110-6.4")] + public void Classify_should_return_None_for_http10_status_without_body(int code) + { + var r = BodySemantics.ClassifyResponse(code, Headers(("Content-Length", "100")), + HttpVersion.Version10, false, connectionWillClose: true); + Assert.Equal(BodyFraming.None, r.Framing); + } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/Headers/PseudoHeaderValueValidationSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/Headers/PseudoHeaderValueValidationSpec.cs new file mode 100644 index 000000000..49fc05996 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Semantics/Headers/PseudoHeaderValueValidationSpec.cs @@ -0,0 +1,67 @@ +using TurboHTTP.Protocol.Semantics; + +namespace TurboHTTP.Tests.Protocol.Semantics.Headers; + +public sealed class PseudoHeaderValueValidationSpec +{ + private static readonly string Section = "RFC 9113 §8.3.1"; + + private static List<(string Name, string Value)> Headers(params (string Name, string Value)[] h) => [..h]; + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.3.1")] + public void ValidateRequestPseudoHeaders_should_reject_empty_path_for_non_CONNECT() + { + var headers = Headers( + (":method", "GET"), + (":path", ""), + (":scheme", "https"), + (":authority", "localhost")); + + var ex = Assert.Throws(() => + PseudoHeaderValidator.ValidateRequestPseudoHeaders( + headers, h => h.Name, h => h.Value, Section)); + + Assert.Contains(":path", ex.Message); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.3.1")] + public void ValidateRequestPseudoHeaders_should_accept_non_empty_path() + { + var headers = Headers( + (":method", "GET"), + (":path", "/"), + (":scheme", "https"), + (":authority", "localhost")); + + PseudoHeaderValidator.ValidateRequestPseudoHeaders( + headers, h => h.Name, h => h.Value, Section); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.3.1")] + public void ValidateRequestPseudoHeaders_should_accept_asterisk_path_for_OPTIONS() + { + var headers = Headers( + (":method", "OPTIONS"), + (":path", "*"), + (":scheme", "https"), + (":authority", "localhost")); + + PseudoHeaderValidator.ValidateRequestPseudoHeaders( + headers, h => h.Name, h => h.Value, Section); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.3.1")] + public void ValidateRequestPseudoHeaders_should_accept_CONNECT_without_path() + { + var headers = Headers( + (":method", "CONNECT"), + (":authority", "proxy.example.com:443")); + + PseudoHeaderValidator.ValidateRequestPseudoHeaders( + headers, h => h.Name, h => h.Value, Section); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs index da31f8b2c..271ad9fdd 100644 --- a/src/TurboHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs @@ -1,19 +1,18 @@ using System.Net; -using Akka.Actor; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Semantics.Redirect; public sealed class UriRedirectSpec { - private static readonly Http11ClientEncoder Encoder = new(Http11ClientEncoderOptions.Default); + private static readonly Http11ClientEncoder Encoder = new(ClientOptionDefaults.Http11Encoder()); private static string EncodeHttp11(HttpRequestMessage request, int bufferSize = 16384) { var buffer = new byte[bufferSize]; - var written = Encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = Encoder.Encode(buffer, request, out _, out _); return System.Text.Encoding.ASCII.GetString(buffer, 0, written); } @@ -45,7 +44,7 @@ public void Http11Encoder_should_encode_extremely_long_uri_when_uri_exceeds_stan var request = new HttpRequestMessage(HttpMethod.Get, longUri); const int bufferSize = 32768; - var written = Encoder.Encode(new byte[bufferSize], request, ActorRefs.Nobody); + var written = Encoder.Encode(new byte[bufferSize], request, out _, out _); Assert.True(written > 0); Assert.True(written < bufferSize); @@ -60,7 +59,7 @@ public void Http11Encoder_should_encode_long_query_string_when_query_parameters_ var request = new HttpRequestMessage(HttpMethod.Get, uri); const int bufferSize = 32768; - var written = Encoder.Encode(new byte[bufferSize], request, ActorRefs.Nobody); + var written = Encoder.Encode(new byte[bufferSize], request, out _, out _); Assert.True(written > 0); Assert.True(written < bufferSize); diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingActivityLeakSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingActivityLeakSpec.cs index 1af382e98..98e446028 100644 --- a/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingActivityLeakSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingActivityLeakSpec.cs @@ -20,7 +20,7 @@ public async Task TracingBidiStage_should_stop_activity_when_stage_tears_down_wi var stoppedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Activity? capturedActivity = null; - var sourceName = Servus.Core.Servus.Tracing.Source.Name; + var sourceName = Servus.Senf.Tracing.Source.Name; using var listener = new ActivityListener(); listener.ShouldListenTo = source => source.Name == sourceName; listener.Sample = (ref _) => ActivitySamplingResult.AllData; @@ -64,7 +64,7 @@ public async Task TracingBidiStage_should_stop_activity_when_stage_tears_down_wi reqInSub.SendNext(request); var forwarded = await reqOutProbe.ExpectNextAsync(TestContext.Current.CancellationToken); - Assert.True(forwarded.Options.TryGetValue(TurboHttpInstrumentationExtensions.RequestActivityKey, + Assert.True(forwarded.Options.TryGetValue(TurboClientInstrumentationExtensions.RequestActivityKey, out var activity)); Assert.NotNull(activity); diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingBidiStageSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingBidiStageSpec.cs index 3f75ecb48..fe947ebd4 100644 --- a/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingBidiStageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Semantics/Tracing/TracingBidiStageSpec.cs @@ -18,7 +18,7 @@ public sealed class TracingBidiStageSpec : StreamTestBase, IDisposable public TracingBidiStageSpec() { - var sourceName = Servus.Core.Servus.Tracing.Source.Name; + var sourceName = Servus.Senf.Tracing.Source.Name; _listener = new ActivityListener { ShouldListenTo = source => source.Name == sourceName, @@ -135,7 +135,7 @@ public async Task TracingBidiStage_should_store_activity_in_request_options() var result = Assert.Single(results); Assert.True(result.Options.TryGetValue( - TurboHttpInstrumentationExtensions.RequestActivityKey, out var activity)); + TurboClientInstrumentationExtensions.RequestActivityKey, out var activity)); Assert.NotNull(activity); } @@ -163,9 +163,9 @@ public async Task TracingBidiStage_should_handle_multiple_requests() Assert.Equal(2, results.Count); Assert.True(results[0].Options.TryGetValue( - TurboHttpInstrumentationExtensions.RequestActivityKey, out var act1)); + TurboClientInstrumentationExtensions.RequestActivityKey, out var act1)); Assert.True(results[1].Options.TryGetValue( - TurboHttpInstrumentationExtensions.RequestActivityKey, out var act2)); + TurboClientInstrumentationExtensions.RequestActivityKey, out var act2)); Assert.NotNull(act1); Assert.NotNull(act2); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderOptionsSpec.cs index 6f0171e02..cb1c674a0 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderOptionsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderOptionsSpec.cs @@ -1,28 +1,12 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Client; namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Client; public sealed class Http10ClientDecoderOptionsSpec { [Fact(Timeout = 5000)] - public void Default_should_hold_SharedHttpOptions_Default() + public void Default_client_options_should_project_sensible_decoder_values() { - Assert.Same(SharedHttpOptions.Default, Http10ClientDecoderOptions.Default.Shared); + Assert.Equal(64L * 1024, new TurboClientOptions().ToHttp10DecoderOptions().StreamingThreshold); } - - [Fact(Timeout = 5000)] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { StreamingThreshold = -1 }; - var opts = Http10ClientDecoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - public void Validate_should_reject_null_Shared() - { - var opts = Http10ClientDecoderOptions.Default with { Shared = null! }; - Assert.Throws(opts.Validate); - } -} \ No newline at end of file +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderSpec.cs index 13f408a3e..16dd5f8ba 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientDecoderSpec.cs @@ -1,14 +1,14 @@ using System.Net; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http10.Client; -using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Client; public sealed class Http10ClientDecoderSpec { private static Http10ClientDecoder MakeDecoder() => - new(Http10ClientDecoderOptions.Default); + new(ClientOptionDefaults.Http10Decoder()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945-6")] @@ -54,10 +54,7 @@ public async Task Decoder_should_attach_buffered_body_below_threshold() [Trait("RFC", "RFC1945-6.2")] public async Task Decoder_should_stream_body_above_threshold() { - var opts = Http10ClientDecoderOptions.Default with - { - Shared = SharedHttpOptions.Default with { StreamingThreshold = 4 }, - }; + var opts = ClientOptionDefaults.Http10Decoder() with { StreamingThreshold = 4 }; var raw = "HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello"u8.ToArray(); var decoder = new Http10ClientDecoder(opts); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientEncoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientEncoderOptionsSpec.cs deleted file mode 100644 index e6c928b35..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientEncoderOptionsSpec.cs +++ /dev/null @@ -1,21 +0,0 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http10.Options; - -namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Client; - -public sealed class Http10ClientEncoderOptionsSpec -{ - [Fact(Timeout = 5000)] - public void Default_should_hold_SharedHttpOptions_Default() - { - Assert.Same(SharedHttpOptions.Default, Http10ClientEncoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { MaxHeaderCount = 0 }; - var opts = Http10ClientEncoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientEncoderSpec.cs index d530a3123..cfe05c31f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientEncoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientEncoderSpec.cs @@ -1,16 +1,11 @@ using System.Text; -using Akka.Actor; -using Akka.TestKit.Xunit; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http10.Client; -using TurboHTTP.Protocol.Syntax.Http10.Options; namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Client; -public sealed class Http10ClientEncoderSpec : TestKit +public sealed class Http10ClientEncoderSpec { - private static Http10ClientEncoder MakeEncoder() => - new(Http10ClientEncoderOptions.Default); + private static Http10ClientEncoder MakeEncoder() => new(); [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945-5")] @@ -20,9 +15,10 @@ public void Encoder_should_emit_request_line_and_no_body_for_GET() request.Headers.TryAddWithoutValidation("User-Agent", "test/1.0"); var buf = new byte[256]; - var written = MakeEncoder().Encode(buf, request, ActorRefs.Nobody); + var written = MakeEncoder().Encode(buf, request, out var bodyStream); var text = Encoding.ASCII.GetString(buf, 0, written); + Assert.Null(bodyStream); Assert.StartsWith("GET /foo HTTP/1.0\r\n", text); Assert.Contains("User-Agent: test/1.0\r\n", text); Assert.EndsWith("\r\n\r\n", text); @@ -35,7 +31,7 @@ public void Encoder_should_omit_Host_header_on_HTTP10() var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var buf = new byte[256]; - var written = MakeEncoder().Encode(buf, request, ActorRefs.Nobody); + var written = MakeEncoder().Encode(buf, request, out _); var text = Encoding.ASCII.GetString(buf, 0, written); Assert.DoesNotContain("Host:", text, StringComparison.OrdinalIgnoreCase); @@ -43,41 +39,38 @@ public void Encoder_should_omit_Host_header_on_HTTP10() [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945-5")] - public void Encode_should_return_zero_for_request_with_body() + public void Encode_should_return_zero_and_body_stream_for_request_with_body() { - var probe = CreateTestProbe(); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/") { Content = new ByteArrayContent("hello"u8.ToArray()) }; var buf = new byte[4096]; - var written = MakeEncoder().Encode(buf, request, probe.Ref); + var written = MakeEncoder().Encode(buf, request, out var bodyStream); Assert.Equal(0, written); - probe.ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(bodyStream); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945-5")] - public void EncodeDeferred_should_write_headers_and_body_with_content_length() + public async Task EncodeDeferred_should_write_headers_and_body_with_content_length() { - var probe = CreateTestProbe(); var encoder = MakeEncoder(); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/") { Content = new ByteArrayContent("hello"u8.ToArray()) }; var buf = new byte[4096]; - encoder.Encode(buf, request, probe.Ref); + encoder.Encode(buf, request, out var bodyStream); + Assert.NotNull(bodyStream); - var chunk = probe.ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); + var bodyBytes = new byte[256]; + var bytesRead = await bodyStream!.ReadAsync(bodyBytes, TestContext.Current.CancellationToken); var deferredBuf = new byte[4096]; - var written = encoder.EncodeDeferred(deferredBuf, request, chunk.Owner.Memory.Span[..chunk.Length]); - chunk.Owner.Dispose(); + var written = encoder.EncodeDeferred(deferredBuf, request, bodyBytes.AsSpan(0, bytesRead)); var result = Encoding.ASCII.GetString(deferredBuf, 0, written); Assert.StartsWith("POST /", result); @@ -93,7 +86,7 @@ public void Encode_should_include_user_agent_when_set() request.Headers.TryAddWithoutValidation("User-Agent", "TurboHTTP/1.0"); var buf = new byte[256]; - var written = MakeEncoder().Encode(buf, request, ActorRefs.Nobody); + var written = MakeEncoder().Encode(buf, request, out _); var text = Encoding.ASCII.GetString(buf, 0, written); Assert.Contains("User-Agent: TurboHTTP/1.0", text); @@ -107,7 +100,7 @@ public void Encode_should_strip_fragment_from_referer() request.Headers.Referrer = new Uri("http://example.com/page#section"); var buf = new byte[512]; - var written = MakeEncoder().Encode(buf, request, ActorRefs.Nobody); + var written = MakeEncoder().Encode(buf, request, out _); var text = Encoding.ASCII.GetString(buf, 0, written); if (text.Contains("Referer:")) @@ -115,4 +108,4 @@ public void Encode_should_strip_fragment_from_referer() Assert.DoesNotContain("#section", text); } } -} \ No newline at end of file +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientStateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientStateMachineSpec.cs index ba833ebc5..02b179e14 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientStateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Client/Http10ClientStateMachineSpec.cs @@ -4,7 +4,6 @@ using Akka.TestKit.Xunit; using Servus.Akka.Transport; using TurboHTTP.Client; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http10.Client; using TurboHTTP.Tests.Shared; @@ -38,7 +37,7 @@ private static TransportBuffer CreateResponseBuffer(string responseText) [Trait("RFC", "RFC1945-5")] public void OnRequest_should_set_endpoint_on_first_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("http://example.com:8080/path")); @@ -52,7 +51,7 @@ public void OnRequest_should_set_endpoint_on_first_request() [Trait("RFC", "RFC1945-5")] public void OnRequest_should_emit_transport_data() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -64,7 +63,7 @@ public void OnRequest_should_emit_transport_data() [Trait("RFC", "RFC1945-5")] public void OnRequest_should_set_in_flight_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -76,13 +75,13 @@ public void OnRequest_should_set_in_flight_request() [Trait("RFC", "RFC1945-6")] public void DecodeServerData_should_decode_complete_response() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var responseBuffer = CreateResponseBuffer("HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello"); - sm.DecodeServerData(new TransportData(responseBuffer)); + sm.DecodeServerData(TransportData.Rent(responseBuffer)); Assert.Single(ops.Responses); Assert.Equal(HttpStatusCode.OK, ops.Responses[0].StatusCode); @@ -92,14 +91,14 @@ public void DecodeServerData_should_decode_complete_response() [Trait("RFC", "RFC1945-6")] public void DecodeServerData_should_set_request_message_on_response() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); var originalRequest = MakeRequest("http://example.com/test"); sm.OnRequest(originalRequest); var responseBuffer = CreateResponseBuffer("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeServerData(new TransportData(responseBuffer)); + sm.DecodeServerData(TransportData.Rent(responseBuffer)); Assert.Single(ops.Responses); Assert.NotNull(ops.Responses[0].RequestMessage); @@ -110,7 +109,7 @@ public void DecodeServerData_should_set_request_message_on_response() [Trait("RFC", "RFC1945")] public void StateMachine_should_handle_full_request_response_cycle() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); var request = MakeRequest("http://example.com/path"); @@ -122,7 +121,7 @@ public void StateMachine_should_handle_full_request_response_cycle() ops.Outbound.Clear(); var responseBuffer = CreateResponseBuffer("HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello"); - sm.DecodeServerData(new TransportData(responseBuffer)); + sm.DecodeServerData(TransportData.Rent(responseBuffer)); Assert.False(sm.HasInFlightRequests); Assert.Single(ops.Responses); @@ -133,7 +132,7 @@ public void StateMachine_should_handle_full_request_response_cycle() [Trait("RFC", "RFC1945")] public void CanAcceptRequest_should_return_false_with_in_flight_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -144,7 +143,7 @@ public void CanAcceptRequest_should_return_false_with_in_flight_request() [Trait("RFC", "RFC1945")] public void CanAcceptRequest_should_return_true_when_idle() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); Assert.True(sm.CanAcceptRequest); @@ -154,7 +153,7 @@ public void CanAcceptRequest_should_return_true_when_idle() [Trait("RFC", "RFC1945-8")] public void Cleanup_should_clear_in_flight_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -165,10 +164,10 @@ public void Cleanup_should_clear_in_flight_request() [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945-5")] - public async Task OnRequest_with_body_should_emit_transport_data_after_body_chunk() + public async Task OnRequest_with_body_should_emit_transport_data_after_body_buffered() { var inbox = Inbox.Create(Sys); - var ops = new FakeOps { StageActor = inbox.Receiver }; + var ops = new FakeClientOps { StageActor = inbox.Receiver }; var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.PreStart(); @@ -180,13 +179,18 @@ public async Task OnRequest_with_body_should_emit_transport_data_after_body_chun Assert.DoesNotContain(ops.Outbound, o => o is TransportData); - var msg = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); - var chunk = Assert.IsType(msg); - sm.OnBodyMessage(chunk); + // SM uses BufferedBodyWriter + PipeTo — will send BodyReadComplete(5) then BodyReadComplete(0) + // then BodyBufferComplete once fully buffered + var msg1 = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); + sm.OnBodyMessage(msg1); var msg2 = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); sm.OnBodyMessage(msg2); + // BodyBufferComplete triggers EncodeDeferred → TransportData + var msg3 = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); + sm.OnBodyMessage(msg3); + Assert.Contains(ops.Outbound, o => o is TransportData); var td = ops.Outbound.OfType().First(); var text = Encoding.ASCII.GetString(td.Buffer.Memory.Span[..td.Buffer.Length]); @@ -198,7 +202,7 @@ public async Task OnRequest_with_body_should_emit_transport_data_after_body_chun [Trait("RFC", "RFC1945-5")] public void OnRequest_with_body_should_block_CanAcceptRequest_until_body_complete() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/") @@ -214,12 +218,12 @@ public void OnRequest_with_body_should_block_CanAcceptRequest_until_body_complet [Trait("RFC", "RFC1945-7")] public void DecodeServerData_should_complete_connection_close_response_on_graceful_disconnect() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var headerBuffer = CreateResponseBuffer("HTTP/1.0 200 OK\r\n\r\nhello"); - sm.DecodeServerData(new TransportData(headerBuffer)); + sm.DecodeServerData(TransportData.Rent(headerBuffer)); Assert.Empty(ops.Responses); @@ -233,15 +237,15 @@ public void DecodeServerData_should_complete_connection_close_response_on_gracef [Trait("RFC", "RFC1945-7")] public void DecodeServerData_should_allow_new_request_after_connection_close_response() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http10ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var headerBuffer = CreateResponseBuffer("HTTP/1.0 200 OK\r\n\r\nhello"); - sm.DecodeServerData(new TransportData(headerBuffer)); + sm.DecodeServerData(TransportData.Rent(headerBuffer)); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Graceful)); Assert.Single(ops.Responses); Assert.True(sm.CanAcceptRequest); } -} \ No newline at end of file +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10DataRateSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10DataRateSpec.cs new file mode 100644 index 000000000..4a8dc5619 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10DataRateSpec.cs @@ -0,0 +1,249 @@ +using System.Buffers; +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Time.Testing; +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http10.Server; +using TurboHTTP.Server; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Server; + +public sealed class Http10DataRateSpec +{ + private static IFeatureCollection CreateResponseContext() + { + var features = new TurboFeatureCollection(); + features.Set(new TurboHttpRequestFeature()); + features.Set(new TurboHttpResponseFeature { StatusCode = 200 }); + var bodyFeature = new TurboHttpResponseBodyFeature(); + features.Set(bodyFeature); + features.Set(bodyFeature); + return features; + } + + private static TransportBuffer MakeBuffer(string raw) + { + var data = Encoding.ASCII.GetBytes(raw); + var buffer = TransportBuffer.Rent(data.Length); + data.CopyTo(buffer.FullMemory.Span); + buffer.Length = data.Length; + return buffer; + } + + private static TransportBuffer MakeBuffer(byte[] data) + { + var buffer = TransportBuffer.Rent(data.Length); + data.CopyTo(buffer.FullMemory.Span); + buffer.Length = data.Length; + return buffer; + } + + private static Http1ConnectionOptions CreateOptionsWithResponseRate(double minRate, TimeSpan grace) + { + var defaultOptions = new TurboServerOptions().ToHttp1Options(); + var newLimits = defaultOptions.Limits with + { + MinResponseDataRate = minRate, + MinResponseDataRateGracePeriod = grace + }; + return defaultOptions with { Limits = newLimits }; + } + + private static Http1ConnectionOptions CreateOptionsWithRequestRate(double minRate, TimeSpan grace) + { + var defaultOptions = new TurboServerOptions().ToHttp1Options(); + var newLimits = defaultOptions.Limits with + { + MinRequestBodyDataRate = minRate, + MinRequestBodyDataRateGracePeriod = grace + }; + return defaultOptions with { Limits = newLimits }; + } + + private static ResponseBodyBuffered MakeBodyBuffered(int size) + { + var owner = MemoryPool.Shared.Rent(size); + return new ResponseBodyBuffered(owner, size); + } + + [Fact(Timeout = 5000)] + public void Data_rate_monitoring_disabled_by_default() + { + var defaultOptions = new TurboServerOptions().ToHttp1Options(); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(defaultOptions, ops); + + var requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + // Simulate a body read cycle: read complete with 0 bytes (EOF), then buffered + var context = CreateResponseContext(); + sm.OnBodyMessage(new ResponseBodyBuffered(MemoryPool.Shared.Rent(0), 0)); + + // Fire timer with monitoring disabled — should not schedule another timer + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Fast_response_body_should_not_violate() + { + var options = CreateOptionsWithResponseRate(100, TimeSpan.FromSeconds(1)); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(options, ops); + + var requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + // Simulate buffered response body complete (removes rate tracking) + sm.OnBodyMessage(MakeBodyBuffered(0)); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Idle_connection_should_not_be_flagged() + { + var options = CreateOptionsWithResponseRate(10000, TimeSpan.FromSeconds(1)); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(options, ops); + + var requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + sm.OnBodyMessage(MakeBodyBuffered(0)); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Response_body_rate_within_grace_period_should_not_violate() + { + var options = CreateOptionsWithResponseRate(1000, TimeSpan.FromSeconds(5)); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(options, ops); + + var requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + sm.OnBodyMessage(MakeBodyBuffered(0)); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Response_completion_should_remove_rate_tracking() + { + var options = CreateOptionsWithResponseRate(10000, TimeSpan.FromMilliseconds(100)); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(options, ops); + + var requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + sm.OnBodyMessage(MakeBodyBuffered(0)); + + System.Threading.Thread.Sleep(150); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Slow_response_body_violation_sets_should_complete_with_injected_clock() + { + var options = CreateOptionsWithResponseRate(1000, TimeSpan.FromSeconds(1)); + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(options, ops, clock); + + var requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + // Simulate reading 10 bytes of response body via ResponseBodyReadComplete + sm.OnBodyMessage(new ResponseBodyReadComplete(10)); + + // Advance clock to first check point (600ms, triggers first rate calculation but still in grace) + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete, "Should be in grace period at first check"); + + // Advance clock past grace period (1700ms total, and grace started at 600ms) + // Now > GracePeriodStart (600) + 1000ms grace = 1600ms, so should violate + clock.Advance(TimeSpan.FromMilliseconds(1100)); + sm.OnTimerFired("data-rate-check"); + Assert.True(sm.ShouldComplete, "Expected data rate violation to set ShouldComplete after grace expires"); + } + + [Fact(Timeout = 5000)] + public void Fast_response_body_within_grace_should_not_violate_with_injected_clock() + { + var options = CreateOptionsWithResponseRate(1000, TimeSpan.FromSeconds(5)); + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(options, ops, clock); + + var requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + sm.OnBodyMessage(MakeBodyBuffered(0)); + + // Check at time=600ms (first rate check, enters grace) + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete); + + // Check at time=3600ms (within 5s grace period from 600ms = 5600ms) — should still be OK + clock.Advance(TimeSpan.FromMilliseconds(3000)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete, "Should not abort when within grace period"); + } + + [Fact(Timeout = 5000)] + public void Slow_request_body_violation_sets_should_complete_with_injected_clock() + { + var options = CreateOptionsWithRequestRate(1000, TimeSpan.FromSeconds(1)); + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var sm = new Http10ServerStateMachine(options, ops, clock); + + // Send request headers + indicate body will come + var requestData = "POST / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 10\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(requestData); + var buffer = MakeBuffer(headerBytes); + sm.DecodeClientData(TransportData.Rent(buffer)); + + // At time=0, send first chunk of body (5 bytes) + var bodyChunk1 = new byte[5]; + var buffer2 = MakeBuffer(bodyChunk1); + sm.DecodeClientData(TransportData.Rent(buffer2)); + + // Advance clock to first check point (600ms) + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete, "Should be in grace period at first check"); + + // Advance clock past grace period (1700ms total) + // Only 5 bytes sent in 1700ms = 2.94 bytes/sec << 1000, so violation + clock.Advance(TimeSpan.FromMilliseconds(1100)); + sm.OnTimerFired("data-rate-check"); + Assert.True(sm.ShouldComplete, "Expected request body data rate violation after grace expires"); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderOptionsSpec.cs deleted file mode 100644 index dec770274..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderOptionsSpec.cs +++ /dev/null @@ -1,20 +0,0 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http10.Options; - -namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Server; - -public sealed class Http10ServerDecoderOptionsSpec -{ - [Fact(Timeout = 5000)] - public void Default_should_hold_SharedHttpOptions_Default() - { - Assert.Same(SharedHttpOptions.Default, Http10ServerDecoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - public void Validate_should_reject_null_Shared() - { - var opts = Http10ServerDecoderOptions.Default with { Shared = null! }; - Assert.Throws(opts.Validate); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSecuritySpec.cs index ee63c6911..854e7880c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSecuritySpec.cs @@ -7,12 +7,22 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Server; public sealed class Http10ServerDecoderSecuritySpec { - private static Http10ServerDecoder MakeDecoder(SharedHttpOptions? shared = null) + private static Http10ServerDecoderOptions DefaultDecoderOptions() => new() { - var options = shared is null - ? Http10ServerDecoderOptions.Default - : new Http10ServerDecoderOptions { Shared = shared }; - return new Http10ServerDecoder(options); + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 4 * 1024 * 1024, + MaxStreamedBodySize = null, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + HeaderLineMaxLength = 8 * 1024, + RequestLineMaxLength = 8 * 1024, + MaxRequestTargetLength = 8 * 1024, + AllowObsFold = false + }; + + private static Http10ServerDecoder MakeDecoder(Http10ServerDecoderOptions? options = null) + { + return new Http10ServerDecoder(options ?? DefaultDecoderOptions()); } [Fact(Timeout = 5000)] @@ -78,8 +88,8 @@ public void Feed_should_accept_duplicate_content_length_with_same_value() [Fact(Timeout = 5000)] public void Feed_should_reject_header_block_exceeding_max_header_bytes() { - var shared = new SharedHttpOptions { MaxHeaderBytes = 64 }; - var decoder = MakeDecoder(shared); + var options = DefaultDecoderOptions() with { MaxHeaderBytes = 64 }; + var decoder = MakeDecoder(options); var headerValue = new string('x', 100); var raw = Encoding.ASCII.GetBytes($"GET / HTTP/1.0\r\nX-Custom: {headerValue}\r\n\r\n"); @@ -89,8 +99,8 @@ public void Feed_should_reject_header_block_exceeding_max_header_bytes() [Fact(Timeout = 5000)] public void Feed_should_reject_header_count_exceeding_max() { - var shared = new SharedHttpOptions { MaxHeaderCount = 2 }; - var decoder = MakeDecoder(shared); + var options = DefaultDecoderOptions() with { MaxHeaderCount = 2 }; + var decoder = MakeDecoder(options); var raw = "GET / HTTP/1.0\r\nX-One: 1\r\nX-Two: 2\r\nX-Three: 3\r\n\r\n"u8.ToArray(); Assert.ThrowsAny(() => decoder.Feed(raw, out _)); @@ -99,8 +109,8 @@ public void Feed_should_reject_header_count_exceeding_max() [Fact(Timeout = 5000)] public void Feed_should_reject_header_line_exceeding_max_length() { - var shared = new SharedHttpOptions { HeaderLineMaxLength = 32 }; - var decoder = MakeDecoder(shared); + var options = DefaultDecoderOptions() with { HeaderLineMaxLength = 32 }; + var decoder = MakeDecoder(options); var longValue = new string('a', 50); var raw = Encoding.ASCII.GetBytes($"GET / HTTP/1.0\r\nX-Long: {longValue}\r\n\r\n"); @@ -110,8 +120,8 @@ public void Feed_should_reject_header_line_exceeding_max_length() [Fact(Timeout = 5000)] public void Feed_should_reject_request_line_exceeding_max_length() { - var shared = new SharedHttpOptions { RequestLineMaxLength = 32 }; - var decoder = MakeDecoder(shared); + var options = DefaultDecoderOptions() with { RequestLineMaxLength = 32 }; + var decoder = MakeDecoder(options); var raw = Encoding.ASCII.GetBytes($"GET /{new string('a', 40)} HTTP/1.0\r\nContent-Length: 0\r\n\r\n"); Assert.ThrowsAny(() => decoder.Feed(raw, out _)); @@ -121,8 +131,8 @@ public void Feed_should_reject_request_line_exceeding_max_length() [Trait("RFC", "RFC9112-5.2")] public void Feed_should_accept_obs_fold_when_allowed() { - var shared = new SharedHttpOptions { AllowObsFold = true }; - var decoder = MakeDecoder(shared); + var options = DefaultDecoderOptions() with { AllowObsFold = true }; + var decoder = MakeDecoder(options); var raw = "GET / HTTP/1.0\r\nX-Multi: value1\r\n continued\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); var outcome = decoder.Feed(raw, out _); @@ -133,8 +143,8 @@ public void Feed_should_accept_obs_fold_when_allowed() [Trait("RFC", "RFC9112-5.2")] public void Feed_should_reject_obs_fold_when_not_allowed() { - var shared = new SharedHttpOptions { AllowObsFold = false }; - var decoder = MakeDecoder(shared); + var options = DefaultDecoderOptions() with { AllowObsFold = false }; + var decoder = MakeDecoder(options); var raw = "GET / HTTP/1.0\r\nX-Multi: value1\r\n continued\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); var ex = Assert.Throws(() => decoder.Feed(raw, out _)); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSpec.cs index 4f46bcb0e..b8364a148 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerDecoderSpec.cs @@ -7,7 +7,20 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Server; public sealed class Http10ServerDecoderSpec { - private static Http10ServerDecoder MakeDecoder() => new(Http10ServerDecoderOptions.Default); + private static Http10ServerDecoderOptions DefaultDecoderOptions() => new() + { + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 4 * 1024 * 1024, + MaxStreamedBodySize = null, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + HeaderLineMaxLength = 8 * 1024, + RequestLineMaxLength = 8 * 1024, + MaxRequestTargetLength = 8 * 1024, + AllowObsFold = false + }; + + private static Http10ServerDecoder MakeDecoder() => new(DefaultDecoderOptions()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945-5")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderFilteringSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderFilteringSpec.cs index 6f17b41b3..49b892f38 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderFilteringSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderFilteringSpec.cs @@ -8,8 +8,14 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Server; public sealed class Http10ServerEncoderFilteringSpec { + private static Http10ServerEncoderOptions DefaultEncoderOptions() => new() + { + WriteDateHeader = false, + MaxHeaderBytes = 32 * 1024, + }; + private static Http10ServerEncoder MakeEncoder(bool withDate = false) => - new(Http10ServerEncoderOptions.Default with { WriteDateHeader = withDate }); + new(DefaultEncoderOptions() with { WriteDateHeader = withDate }); [Theory(Timeout = 5000)] [Trait("RFC", "RFC9110-7.6.1")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderOptionsSpec.cs deleted file mode 100644 index eb8f66f67..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderOptionsSpec.cs +++ /dev/null @@ -1,29 +0,0 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http10.Options; - -namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Server; - -public sealed class Http10ServerEncoderOptionsSpec -{ - [Fact(Timeout = 5000)] - public void Default_should_hold_SharedHttpOptions_Default_and_WriteDateHeader_true() - { - var d = Http10ServerEncoderOptions.Default; - Assert.Same(SharedHttpOptions.Default, d.Shared); - Assert.True(d.WriteDateHeader); - } - - [Fact(Timeout = 5000)] - public void With_should_disable_WriteDateHeader() - { - var opts = Http10ServerEncoderOptions.Default with { WriteDateHeader = false }; - Assert.False(opts.WriteDateHeader); - } - - [Fact(Timeout = 5000)] - public void Validate_should_reject_null_Shared() - { - var opts = Http10ServerEncoderOptions.Default with { Shared = null! }; - Assert.Throws(opts.Validate); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderSpec.cs index 079cbedf8..fdb8508b4 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerEncoderSpec.cs @@ -8,8 +8,14 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http10.Server; public sealed class Http10ServerEncoderSpec : TestKit { + private static Http10ServerEncoderOptions DefaultEncoderOptions() => new() + { + WriteDateHeader = true, + MaxHeaderBytes = 32 * 1024, + }; + private static Http10ServerEncoder MakeEncoder(bool withDate = true) => - new(Http10ServerEncoderOptions.Default with { WriteDateHeader = withDate }); + new(DefaultEncoderOptions() with { WriteDateHeader = withDate }); [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945-6")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineErrorSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineErrorSpec.cs index 61bd2a6a7..dee57c25c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineErrorSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineErrorSpec.cs @@ -3,7 +3,6 @@ using Akka.TestKit.Xunit; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http10.Server; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; @@ -15,7 +14,7 @@ public sealed class Http10ServerStateMachineErrorSpec : TestKit { private static FakeServerOps MakeOps() => new(); - private static IFeatureCollection CreateResponseContext() + private static TurboFeatureCollection CreateResponseContext() { var features = new TurboFeatureCollection(); features.Set(new TurboHttpRequestFeature()); @@ -26,16 +25,6 @@ private static IFeatureCollection CreateResponseContext() return features; } - private static async Task CreateResponseContextWithBody(string body) - { - var context = CreateResponseContext(); - var bodyFeature = context.Get()!; - var bytes = Encoding.ASCII.GetBytes(body); - await bodyFeature.Writer.WriteAsync(bytes); - await bodyFeature.Writer.CompleteAsync(); - return context; - } - private static TransportBuffer CreateRequestBuffer(string requestText) { var bytes = Encoding.ASCII.GetBytes(requestText); @@ -49,11 +38,11 @@ private static TransportBuffer CreateRequestBuffer(string requestText) public void DecodeClientData_should_set_ShouldComplete_on_decode_error() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var requestBuffer = CreateRequestBuffer("POST / HTTP/1.0\r\nContent-Length: abc\r\n\r\n"); - sm.DecodeClientData(new TransportData(requestBuffer)); + sm.DecodeClientData(TransportData.Rent(requestBuffer)); Assert.True(sm.ShouldComplete); Assert.Empty(ops.Requests); @@ -63,13 +52,13 @@ public void DecodeClientData_should_set_ShouldComplete_on_decode_error() public void DecodeClientData_should_not_crash_after_prior_decode_error() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var invalidBuffer = CreateRequestBuffer("POST / HTTP/1.0\r\nContent-Length: abc\r\n\r\n"); - sm.DecodeClientData(new TransportData(invalidBuffer)); + sm.DecodeClientData(TransportData.Rent(invalidBuffer)); var validBuffer = CreateRequestBuffer("GET / HTTP/1.0\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - var ex = Record.Exception(() => sm.DecodeClientData(new TransportData(validBuffer))); + var ex = Record.Exception(() => sm.DecodeClientData(TransportData.Rent(validBuffer))); Assert.Null(ex); } @@ -78,7 +67,7 @@ public void DecodeClientData_should_not_crash_after_prior_decode_error() public void Cleanup_should_be_idempotent() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var ex1 = Record.Exception(() => sm.Cleanup()); var ex2 = Record.Exception(() => sm.Cleanup()); @@ -88,19 +77,26 @@ public void Cleanup_should_be_idempotent() } [Fact(Timeout = 5000)] - public async Task Cleanup_should_dispose_deferred_body_owner() + public async Task Cleanup_should_not_throw_when_body_read_in_progress() { var inbox = Inbox.Create(Sys); var ops = new FakeServerOps { StageActor = inbox.Receiver }; - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); sm.PreStart(); - var context = await CreateResponseContextWithBody("hello"); + var context = CreateResponseContext(); + var bodyFeature = (TurboHttpResponseBodyFeature)context.Get()!; + bodyFeature.UpgradeToPipe(); + var bytes = "hello"u8.ToArray(); + await bodyFeature.Writer.WriteAsync(bytes, TestContext.Current.CancellationToken); + sm.OnResponse(context); - var msg = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); - var chunk = Assert.IsType(msg); - sm.OnBodyMessage(chunk); + await bodyFeature.Writer.CompleteAsync(); + + // Receive the body message but do NOT dispatch it — + // simulates Cleanup arriving while a body read is in-flight. + await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); var ex = Record.Exception(() => sm.Cleanup()); @@ -111,7 +107,7 @@ public async Task Cleanup_should_dispose_deferred_body_owner() public void OnBodyMessage_should_ignore_unknown_message_type() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var ex = Record.Exception(() => sm.OnBodyMessage("unknown message")); @@ -119,12 +115,12 @@ public void OnBodyMessage_should_ignore_unknown_message_type() } [Fact(Timeout = 5000)] - public void OnBodyMessage_OutboundBodyFailed_should_not_crash_without_prior_response() + public void OnBodyMessage_ResponseBodyReadFailed_should_not_crash_without_prior_response() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); - var failedMsg = new OutboundBodyFailed(new Exception("Body read failed")); + var failedMsg = new ResponseBodyReadFailed(new Exception("Body read failed")); var ex = Record.Exception(() => sm.OnBodyMessage(failedMsg)); Assert.Null(ex); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineSpec.cs index 1992b0737..316163127 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Server/Http10ServerStateMachineSpec.cs @@ -1,9 +1,7 @@ using System.Text; -using Akka.Actor; using Akka.TestKit.Xunit; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http10.Server; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; @@ -50,11 +48,11 @@ private static TransportBuffer CreateRequestBuffer(string requestText) public void DecodeClientData_should_decode_complete_request() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var requestBuffer = CreateRequestBuffer("GET /path HTTP/1.0\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(requestBuffer)); + sm.DecodeClientData(TransportData.Rent(requestBuffer)); Assert.Single(ops.Requests); var req = ops.Requests[0].Get()!; @@ -67,11 +65,11 @@ public void DecodeClientData_should_decode_complete_request() public void DecodeClientData_should_not_complete_before_response() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var requestBuffer = CreateRequestBuffer("GET / HTTP/1.0\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(requestBuffer)); + sm.DecodeClientData(TransportData.Rent(requestBuffer)); Assert.False(sm.ShouldComplete); Assert.Single(ops.Requests); @@ -82,7 +80,7 @@ public void DecodeClientData_should_not_complete_before_response() public void OnResponse_should_not_emit_transport_data_before_body_delivered() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var context = CreateResponseContext(); @@ -93,25 +91,14 @@ public void OnResponse_should_not_emit_transport_data_before_body_delivered() [Fact(Timeout = 5000)] [Trait("RFC", "RFC1945")] - public async Task OnResponse_with_body_should_emit_transport_data_after_body_chunk() + public async Task OnResponse_with_body_should_emit_transport_data_after_body_buffered() { - var inbox = Inbox.Create(Sys); - var ops = new FakeServerOps { StageActor = inbox.Receiver }; - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); - sm.PreStart(); + var ops = MakeOps(); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var context = await CreateResponseContextWithBody("hello"); sm.OnResponse(context); - Assert.DoesNotContain(ops.Outbound, o => o is TransportData); - - var msg = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); - var chunk = Assert.IsType(msg); - sm.OnBodyMessage(chunk); - - var msg2 = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); - sm.OnBodyMessage(msg2); - Assert.Contains(ops.Outbound, o => o is TransportData); var td = ops.Outbound.OfType().First(); var text = Encoding.ASCII.GetString(td.Buffer.Memory.Span[..td.Buffer.Length]); @@ -124,7 +111,7 @@ public async Task OnResponse_with_body_should_emit_transport_data_after_body_chu public void OnResponse_should_add_connection_close_header() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var context = CreateResponseContext(); @@ -138,7 +125,7 @@ public void OnResponse_should_add_connection_close_header() public void CanAcceptResponse_should_always_be_true() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); Assert.True(sm.CanAcceptResponse); } @@ -148,7 +135,7 @@ public void CanAcceptResponse_should_always_be_true() public void Cleanup_should_abort_active_body() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); sm.Cleanup(); @@ -159,24 +146,15 @@ public void Cleanup_should_abort_active_body() [Trait("RFC", "RFC1945-3.1")] public async Task OnResponse_should_use_http10_version_in_status_line() { - var inbox = Inbox.Create(Sys); - var ops = new FakeServerOps { StageActor = inbox.Receiver }; - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); - sm.PreStart(); + var ops = MakeOps(); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var requestBuffer = CreateRequestBuffer("GET / HTTP/1.0\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(requestBuffer)); + sm.DecodeClientData(TransportData.Rent(requestBuffer)); var context = await CreateResponseContextWithBody("hello"); sm.OnResponse(context); - var msg = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); - var chunk = Assert.IsType(msg); - sm.OnBodyMessage(chunk); - - var msg2 = await Task.Run(() => inbox.Receive(TimeSpan.FromSeconds(3))); - sm.OnBodyMessage(msg2); - var td = ops.Outbound.OfType().First(); var text = Encoding.ASCII.GetString(td.Buffer.Memory.Span[..td.Buffer.Length]); Assert.StartsWith("HTTP/1.0 ", text); @@ -187,10 +165,10 @@ public async Task OnResponse_should_use_http10_version_in_status_line() public void DecodeClientData_should_signal_error_for_unknown_method() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var requestBuffer = CreateRequestBuffer("PATCH /path HTTP/1.0\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(requestBuffer)); + sm.DecodeClientData(TransportData.Rent(requestBuffer)); Assert.Single(ops.Requests); var req = ops.Requests[0].Get()!; @@ -202,10 +180,10 @@ public void DecodeClientData_should_signal_error_for_unknown_method() public void DecodeClientData_should_detect_simple_request() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var requestBuffer = CreateRequestBuffer("GET /path\r\n"); - sm.DecodeClientData(new TransportData(requestBuffer)); + sm.DecodeClientData(TransportData.Rent(requestBuffer)); Assert.True(ops.Requests.Count <= 1); } @@ -215,10 +193,10 @@ public void DecodeClientData_should_detect_simple_request() public void DecodeClientData_should_handle_post_without_content_length() { var ops = MakeOps(); - var sm = new Http10ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http10ServerStateMachine(new TurboServerOptions().ToHttp1Options(), ops); var requestBuffer = CreateRequestBuffer("POST /path HTTP/1.0\r\nHost: example.com\r\n\r\n"); - sm.DecodeClientData(new TransportData(requestBuffer)); + sm.DecodeClientData(TransportData.Rent(requestBuffer)); if (ops.Requests.Count > 0) { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageReconnectSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageReconnectSpec.cs index 189135546..adc3437d5 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageReconnectSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageReconnectSpec.cs @@ -86,7 +86,7 @@ public async Task Http10ConnectionStage_should_reconnect_and_replay_request_on_c // Now respond normally var responseBuffer = MakeResponseBuffer("HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello"); - serverSub.SendNext(new TransportData(responseBuffer)); + serverSub.SendNext(TransportData.Rent(responseBuffer)); var response = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageSpec.cs index c8f1fffb9..756d2c539 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10ConnectionStageSpec.cs @@ -123,7 +123,7 @@ public async Task Http10ConnectionStage_should_decode_response_and_correlate_wit // Send response from server const string responseRaw = "HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello"; - serverSubscription.SendNext(new TransportData(MakeResponseBuffer(responseRaw))); + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer(responseRaw))); // Should get correlated response var response = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); @@ -175,7 +175,7 @@ public async Task Http10ConnectionStage_should_emit_connection_reuse_close_for_h await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); serverSubscription.SendNext( - new TransportData(MakeResponseBuffer("HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nOK"))); + TransportData.Rent(MakeResponseBuffer("HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nOK"))); // Response await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientDecoderSpec.cs index 8329df29c..de4bf1e8f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientDecoderSpec.cs @@ -1,13 +1,13 @@ using System.Text; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Client; public sealed class Http11ClientDecoderSpec { - private readonly Http11ClientDecoder _decoder = new(Http11ClientDecoderOptions.Default); + private readonly Http11ClientDecoder _decoder = new(ClientOptionDefaults.Http11Decoder()); [Fact(Timeout = 5000)] public void Feed_should_decode_simple_response() @@ -66,7 +66,7 @@ public void Reset_should_clear_state() public void Feed_should_handle_bare_cr_in_status_line() { var raw = "HTTP/1.1 200\rOK\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var outcome = decoder.Feed(raw, requestMethodWasHead: false, out _); @@ -82,7 +82,7 @@ public void Feed_should_treat_http10_with_transfer_encoding_as_faulty() "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\nhello\r\n0\r\n\r\n"); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var ex = Assert.Throws(() => decoder.Feed(raw, requestMethodWasHead: false, out _)); Assert.Contains("Transfer-Encoding", ex.Message); @@ -100,7 +100,7 @@ public void Feed_should_not_merge_trailers_into_response_headers() "0\r\n" + "X-Checksum: abc123\r\n" + "\r\n"); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var outcome = decoder.Feed(raw, requestMethodWasHead: false, out _); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientEncoderSpec.cs index 7e2614b18..f393881cb 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientEncoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientEncoderSpec.cs @@ -1,14 +1,13 @@ using System.Text; using System.Text.RegularExpressions; -using Akka.Actor; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Client; public sealed class Http11ClientEncoderSpec { - private readonly Http11ClientEncoder _encoder = new(Http11ClientEncoderOptions.Default); + private readonly Http11ClientEncoder _encoder = new(ClientOptionDefaults.Http11Encoder()); [Fact(Timeout = 5000)] public void Encode_should_write_request_line() @@ -16,7 +15,7 @@ public void Encode_should_write_request_line() var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); var buffer = new byte[4096]; - var written = _encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = _encoder.Encode(buffer, request, out _, out _); Assert.True(written > 0); var result = Encoding.ASCII.GetString(buffer, 0, written); @@ -29,7 +28,7 @@ public void Encode_should_add_host_header() var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com:8080/path"); var buffer = new byte[4096]; - var written = _encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = _encoder.Encode(buffer, request, out _, out _); var result = Encoding.ASCII.GetString(buffer, 0, written); Assert.Contains("Host: example.com:8080", result); @@ -44,7 +43,7 @@ public void Encode_should_write_headers_with_content_length() }; var buffer = new byte[4096]; - var written = _encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = _encoder.Encode(buffer, request, out _, out _); Assert.True(written > 0); var result = Encoding.ASCII.GetString(buffer, 0, written); @@ -58,7 +57,7 @@ public void Encode_should_write_connection_header() var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var buffer = new byte[4096]; - var written = _encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = _encoder.Encode(buffer, request, out _, out _); var result = Encoding.ASCII.GetString(buffer, 0, written); Assert.Contains("Connection:", result); @@ -71,7 +70,7 @@ public void Encode_should_end_headers_with_crlf_crlf() var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var buffer = new byte[4096]; - var written = _encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = _encoder.Encode(buffer, request, out _, out _); var result = Encoding.ASCII.GetString(buffer, 0, written); Assert.True(result.Contains("\r\n"), "Output should use CRLF line endings"); @@ -85,7 +84,7 @@ public void Encode_should_separate_header_block_from_body_with_blank_line() var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var buffer = new byte[4096]; - var written = _encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = _encoder.Encode(buffer, request, out _, out _); var result = Encoding.ASCII.GetString(buffer, 0, written); Assert.True(result.Contains("\r\n\r\n"), @@ -99,7 +98,7 @@ public void Encode_should_format_request_line_correctly() var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/api/resource"); var buffer = new byte[4096]; - var written = _encoder.Encode(buffer, request, ActorRefs.Nobody); + var written = _encoder.Encode(buffer, request, out _, out _); var result = Encoding.ASCII.GetString(buffer, 0, written); var firstLine = result[..result.IndexOf("\r\n")]; diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11HeaderReuseSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11HeaderReuseSpec.cs index d1ba7cbad..9027fc3bb 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11HeaderReuseSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11HeaderReuseSpec.cs @@ -1,7 +1,6 @@ using System.Text; -using Akka.Actor; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Client; @@ -10,16 +9,16 @@ public sealed class Http11HeaderReuseSpec [Fact(Timeout = 5000)] public void Encode_should_produce_valid_output_on_second_call() { - var encoder = new Http11ClientEncoder(Http11ClientEncoderOptions.Default); + var encoder = new Http11ClientEncoder(ClientOptionDefaults.Http11Encoder()); var request1 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/first"); var buffer1 = new byte[4 * 1024]; - var written1 = encoder.Encode(buffer1, request1, ActorRefs.Nobody); + var written1 = encoder.Encode(buffer1, request1, out _, out _); var result1 = Encoding.ASCII.GetString(buffer1, 0, written1); var request2 = new HttpRequestMessage(HttpMethod.Post, "http://example.com/second"); var buffer2 = new byte[4 * 1024]; - var written2 = encoder.Encode(buffer2, request2, ActorRefs.Nobody); + var written2 = encoder.Encode(buffer2, request2, out _, out _); var result2 = Encoding.ASCII.GetString(buffer2, 0, written2); Assert.Contains("GET /first HTTP/1.1", result1); @@ -31,17 +30,17 @@ public void Encode_should_produce_valid_output_on_second_call() [Fact(Timeout = 5000)] public void Encode_should_not_leak_headers_between_calls() { - var encoder = new Http11ClientEncoder(Http11ClientEncoderOptions.Default); + var encoder = new Http11ClientEncoder(ClientOptionDefaults.Http11Encoder()); var request1 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); request1.Headers.Add("X-Custom", "value1"); var buffer1 = new byte[4 * 1024]; - var written1 = encoder.Encode(buffer1, request1, ActorRefs.Nobody); + var written1 = encoder.Encode(buffer1, request1, out _, out _); var result1 = Encoding.ASCII.GetString(buffer1, 0, written1); var request2 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var buffer2 = new byte[4 * 1024]; - var written2 = encoder.Encode(buffer2, request2, ActorRefs.Nobody); + var written2 = encoder.Encode(buffer2, request2, out _, out _); var result2 = Encoding.ASCII.GetString(buffer2, 0, written2); Assert.Contains("X-Custom: value1", result1); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11IncompleteMessageSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11IncompleteMessageSpec.cs index 918ec6882..48d54d3e6 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11IncompleteMessageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11IncompleteMessageSpec.cs @@ -1,13 +1,13 @@ using System.Text; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Client; public sealed class Http11IncompleteMessageSpec { - private readonly Http11ClientDecoder _decoder = new(Http11ClientDecoderOptions.Default); + private readonly Http11ClientDecoder _decoder = new(ClientOptionDefaults.Http11Decoder()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-6.3")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineDisconnectSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineDisconnectSpec.cs index 280871c3e..cf1e1ea51 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineDisconnectSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineDisconnectSpec.cs @@ -35,9 +35,9 @@ private static TransportBuffer CreateResponseBuffer(string responseText) [Trait("RFC", "RFC9112-9.6")] public void Http11StateMachine_should_fail_inflight_on_abrupt_disconnect() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxReconnectAttempts = 0 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxReconnectAttempts = 0 } }); var (request, pending) = MakeTrackedRequest(); sm.OnRequest(request); @@ -51,13 +51,13 @@ public void Http11StateMachine_should_fail_inflight_on_abrupt_disconnect() [Trait("RFC", "RFC9112-9.6")] public void Http11StateMachine_should_try_eof_decode_on_graceful_disconnect() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, new TurboClientOptions()); sm.OnRequest(MakeRequest()); const string response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello"; - sm.DecodeServerData(new TransportData(CreateResponseBuffer(response))); + sm.DecodeServerData(TransportData.Rent(CreateResponseBuffer(response))); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Graceful)); @@ -68,9 +68,9 @@ public void Http11StateMachine_should_try_eof_decode_on_graceful_disconnect() [Trait("RFC", "RFC9112-9.6")] public void Http11StateMachine_should_reconnect_on_disconnect_with_inflight() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxReconnectAttempts = 3 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxReconnectAttempts = 3 } }); sm.OnRequest(MakeRequest()); ops.Outbound.Clear(); @@ -85,9 +85,9 @@ public void Http11StateMachine_should_reconnect_on_disconnect_with_inflight() [Trait("RFC", "RFC9112-9.6")] public void Http11StateMachine_should_replay_buffered_requests_on_reconnect() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxReconnectAttempts = 3 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxReconnectAttempts = 3 } }); sm.OnRequest(MakeRequest()); sm.OnRequest(MakeRequest("http://example.com/other")); @@ -106,9 +106,9 @@ public void Http11StateMachine_should_replay_buffered_requests_on_reconnect() [Trait("RFC", "RFC9112-9.6")] public void Http11StateMachine_should_fail_buffered_on_max_reconnect_exceeded() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxReconnectAttempts = 1 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxReconnectAttempts = 1 } }); var (request, pending) = MakeTrackedRequest(); sm.OnRequest(request); @@ -124,7 +124,7 @@ public void Http11StateMachine_should_fail_buffered_on_max_reconnect_exceeded() [Trait("RFC", "RFC9112-9.6")] public void OnUpstreamFinished_should_fail_orphaned_requests() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, new TurboClientOptions()); var (request, pending) = MakeTrackedRequest(); @@ -139,9 +139,9 @@ public void OnUpstreamFinished_should_fail_orphaned_requests() [Trait("RFC", "RFC9112-9.6")] public void OnUpstreamFinished_should_fail_buffered_queue_when_reconnecting() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxReconnectAttempts = 3 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxReconnectAttempts = 3 } }); var (request, pending) = MakeTrackedRequest(); sm.OnRequest(request); @@ -158,7 +158,7 @@ public void OnUpstreamFinished_should_fail_buffered_queue_when_reconnecting() [Trait("RFC", "RFC9112-9.3")] public void Cleanup_should_clear_all_state() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, new TurboClientOptions()); sm.OnRequest(MakeRequest()); @@ -174,9 +174,9 @@ public void Cleanup_should_clear_all_state() [Trait("RFC", "RFC9112-9.3")] public void PendingRequestCount_should_reflect_inflight_queue() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxPipelineDepth = 4 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxPipelineDepth = 4 } }); Assert.Equal(0, sm.PendingRequestCount); @@ -190,9 +190,9 @@ public void PendingRequestCount_should_reflect_inflight_queue() [Trait("RFC", "RFC9112-9.3")] public void PendingRequestCount_should_reflect_reconnect_buffer() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxReconnectAttempts = 3, MaxPipelineDepth = 4 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxReconnectAttempts = 3, MaxPipelineDepth = 4 } }); sm.OnRequest(MakeRequest()); sm.OnRequest(MakeRequest("http://example.com/b")); @@ -207,9 +207,9 @@ public void PendingRequestCount_should_reflect_reconnect_buffer() [Trait("RFC", "RFC9112-9.3")] public void CanAcceptRequest_should_be_false_when_pipeline_full() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxPipelineDepth = 1 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxPipelineDepth = 1 } }); sm.OnRequest(MakeRequest()); @@ -220,9 +220,9 @@ public void CanAcceptRequest_should_be_false_when_pipeline_full() [Trait("RFC", "RFC9112-9.3")] public void CanAcceptRequest_should_be_false_when_reconnecting() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, - new TurboClientOptions { Http1 = new Http1Options { MaxReconnectAttempts = 3 } }); + new TurboClientOptions { Http1 = new Http1ClientOptions { MaxReconnectAttempts = 3 } }); sm.OnRequest(MakeRequest()); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineReconnectSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineReconnectSpec.cs index f7d1742eb..c663eeeb0 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineReconnectSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineReconnectSpec.cs @@ -31,7 +31,7 @@ private static (HttpRequestMessage Request, PendingRequest Pending) MakeTrackedR private static TurboClientOptions MakeConfig(int maxPipelineDepth = 4, int maxReconnectAttempts = 3) => new() { - Http1 = new Http1Options + Http1 = new Http1ClientOptions { MaxPipelineDepth = maxPipelineDepth, MaxReconnectAttempts = maxReconnectAttempts @@ -47,7 +47,7 @@ private static TurboClientOptions MakeConfig(int maxPipelineDepth = 4, int maxRe [Trait("RFC", "RFC9112-9.3")] public void DecodeServerData_should_start_reconnect_on_disconnect_with_inflight_requests() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/a")); sm.OnRequest(MakeRequest("/b")); @@ -64,7 +64,7 @@ public void DecodeServerData_should_start_reconnect_on_disconnect_with_inflight_ [Trait("RFC", "RFC9112-9.3")] public void DecodeServerData_should_set_CanAcceptRequest_false_when_reconnecting() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -77,7 +77,7 @@ public void DecodeServerData_should_set_CanAcceptRequest_false_when_reconnecting [Trait("RFC", "RFC9112-9.3")] public void DecodeServerData_should_replay_buffered_requests_on_connection_restored() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/a")); sm.OnRequest(MakeRequest("/b")); @@ -96,7 +96,7 @@ public void DecodeServerData_should_replay_buffered_requests_on_connection_resto [Trait("RFC", "RFC9112-9.3")] public void DecodeServerData_should_fail_requests_when_max_reconnect_attempts_exceeded() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig(maxReconnectAttempts: 1)); var (request, pending) = MakeTrackedRequest(); sm.OnRequest(request); @@ -114,7 +114,7 @@ public void DecodeServerData_should_fail_requests_when_max_reconnect_attempts_ex [Trait("RFC", "RFC9112-9.3")] public void DecodeServerData_should_emit_new_connect_when_reconnect_attempt_under_limit() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig(maxReconnectAttempts: 3)); sm.OnRequest(MakeRequest()); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineSpec.cs index 9c876808c..e0723826e 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11StateMachineSpec.cs @@ -2,7 +2,6 @@ using Servus.Akka.Transport; using TurboHTTP.Client; using TurboHTTP.Internal; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http11.Client; using TurboHTTP.Tests.Shared; @@ -13,7 +12,7 @@ public sealed class Http11StateMachineSpec private static TurboClientOptions MakeConfig(int maxPipelineDepth = 8) => new() { - Http1 = new Http1Options + Http1 = new Http1ClientOptions { MaxPipelineDepth = maxPipelineDepth } @@ -79,7 +78,7 @@ private static TransportBuffer CreateResponseBuffer(string response) [Trait("RFC", "RFC9112-6")] public void OnRequest_should_enqueue_request_and_emit_stream_acquire() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -91,7 +90,7 @@ public void OnRequest_should_enqueue_request_and_emit_stream_acquire() [Trait("RFC", "RFC9112-6")] public void OnRequest_should_emit_network_buffer_with_encoded_data() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -106,7 +105,7 @@ public void OnRequest_should_emit_network_buffer_with_encoded_data() [Trait("RFC", "RFC9112-6")] public void OnRequest_should_set_endpoint_on_first_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); @@ -118,7 +117,7 @@ public void OnRequest_should_set_endpoint_on_first_request() [Trait("RFC", "RFC9112-6")] public void OnRequest_should_respect_max_pipeline_depth() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig(maxPipelineDepth: 2)); sm.OnRequest(MakeRequest("/1")); @@ -131,7 +130,7 @@ public void OnRequest_should_respect_max_pipeline_depth() [Trait("RFC", "RFC9112-6")] public void OnRequest_should_handle_post_request_with_content() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); var content = new StringContent("test body", Encoding.UTF8); @@ -149,7 +148,7 @@ public void OnRequest_should_handle_post_request_with_content() [Trait("RFC", "RFC9112-6")] public void OnRequest_should_emit_multiple_requests_in_pipeline() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); @@ -169,7 +168,7 @@ public void OnRequest_should_emit_multiple_requests_in_pipeline() [Trait("RFC", "RFC9112-6")] public void OnRequest_should_handle_request_without_content() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/", "GET")); @@ -184,7 +183,7 @@ public void OnRequest_should_handle_request_without_content() [Trait("RFC", "RFC9112-6")] public void OnRequest_should_respect_max_buffer_size() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); var content = new StringContent("test", Encoding.UTF8); @@ -200,12 +199,12 @@ public void OnRequest_should_respect_max_buffer_size() [Trait("RFC", "RFC9112-6")] public void DecodeServerData_should_decode_single_response() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.Single(ops.Responses); Assert.Equal((int)System.Net.HttpStatusCode.OK, (int)ops.Responses[0].StatusCode); @@ -215,19 +214,19 @@ public void DecodeServerData_should_decode_single_response() [Trait("RFC", "RFC9112-6")] public void DecodeServerData_should_emit_connection_reuse_item() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.3")] public void DecodeServerData_should_decode_multiple_pipelined_responses() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); @@ -235,7 +234,7 @@ public void DecodeServerData_should_decode_multiple_pipelined_responses() var buffer = CreateResponseBuffer( "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK" + "HTTP/1.1 201 Created\r\nContent-Length: 7\r\n\r\nCreated"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.Equal(2, ops.Responses.Count); Assert.Equal((int)System.Net.HttpStatusCode.OK, (int)ops.Responses[0].StatusCode); @@ -244,46 +243,44 @@ public void DecodeServerData_should_decode_multiple_pipelined_responses() [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.8")] - public void DecodeServerData_should_buffer_close_delimited_response() + public void DecodeServerData_should_push_streaming_response_immediately_for_close_delimited() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); - Assert.Empty(ops.Responses); + Assert.Single(ops.Responses); + Assert.Equal(200, (int)ops.Responses[0].StatusCode); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.8")] - public void DecodeServerData_should_accumulate_body_for_close_delimited_response() + public void DecodeServerData_should_push_response_before_body_complete_for_streaming() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer1 = CreateResponseBuffer("HTTP/1.1 200 OK\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer1)); - - var buffer2 = CreateResponseBuffer("response body"); - sm.DecodeServerData(new TransportData(buffer2)); + sm.DecodeServerData(TransportData.Rent(buffer1)); - Assert.Empty(ops.Responses); + Assert.Single(ops.Responses); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.8")] public void DecodeServerData_should_handle_connection_close_header() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.Single(ops.Responses); Assert.False(sm.CanAcceptRequest); @@ -293,11 +290,11 @@ public void DecodeServerData_should_handle_connection_close_header() [Trait("RFC", "RFC9112-6")] public void DecodeServerData_should_handle_graceful_disconnect() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Graceful)); @@ -308,14 +305,14 @@ public void DecodeServerData_should_handle_graceful_disconnect() [Trait("RFC", "RFC9112-6")] public void DecodeServerData_should_clear_effective_pipeline_depth_when_connection_close_with_multiple_inflight() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); sm.OnRequest(MakeRequest("/3")); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nOK"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.False(sm.CanAcceptRequest); } @@ -324,13 +321,13 @@ public void DecodeServerData_should_clear_effective_pipeline_depth_when_connecti [Trait("RFC", "RFC9112-6")] public void DecodeServerData_should_preserve_request_reference() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); var req = MakeRequest(); sm.OnRequest(req); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.NotNull(ops.Responses[0].RequestMessage); } @@ -339,14 +336,14 @@ public void DecodeServerData_should_preserve_request_reference() [Trait("RFC", "RFC9112-9.8")] public void DecodeServerData_should_complete_close_delimited_response_on_graceful_disconnect() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer1 = CreateResponseBuffer("HTTP/1.1 200 OK\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer1)); + sm.DecodeServerData(TransportData.Rent(buffer1)); var buffer2 = CreateResponseBuffer("body content"); - sm.DecodeServerData(new TransportData(buffer2)); + sm.DecodeServerData(TransportData.Rent(buffer2)); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Graceful)); @@ -356,32 +353,31 @@ public void DecodeServerData_should_complete_close_delimited_response_on_gracefu [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.8")] - public void DecodeServerData_should_fail_request_on_abrupt_close_with_pending_close_delimited() + public void DecodeServerData_should_push_response_immediately_for_streaming_then_handle_abrupt_close() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); var (request, pending) = MakeTrackedRequest(); sm.OnRequest(request); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); - sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); + Assert.Single(ops.Responses); - var task = pending.GetValueTask(); - Assert.True(task.IsFaulted); + sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.8")] public void DecodeServerData_should_decode_eof_response_on_graceful_disconnect() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Graceful)); @@ -392,13 +388,13 @@ public void DecodeServerData_should_decode_eof_response_on_graceful_disconnect() [Trait("RFC", "RFC9112-9.8")] public void DecodeServerData_should_stay_alive_after_abrupt_close_when_no_pending() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); var (request, _) = MakeTrackedRequest(); sm.OnRequest(request); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); @@ -407,29 +403,25 @@ public void DecodeServerData_should_stay_alive_after_abrupt_close_when_no_pendin [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.8")] - public void DecodeServerData_should_fail_request_on_abrupt_close_with_body_owners() + public void DecodeServerData_should_push_response_immediately_then_handle_abrupt_close_with_body() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); - var (request, pending) = MakeTrackedRequest(); - sm.OnRequest(request); + sm.OnRequest(MakeRequest()); var buffer1 = CreateResponseBuffer("HTTP/1.1 200 OK\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer1)); - var buffer2 = CreateResponseBuffer("body"); - sm.DecodeServerData(new TransportData(buffer2)); + sm.DecodeServerData(TransportData.Rent(buffer1)); - sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); + Assert.Single(ops.Responses); - var task = pending.GetValueTask(); - Assert.True(task.IsFaulted); + sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-9.8")] public void OnUpstreamFinished_should_complete_when_no_inflight_requests() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnUpstreamFinished(); @@ -441,7 +433,7 @@ public void OnUpstreamFinished_should_complete_when_no_inflight_requests() [Trait("RFC", "RFC9112-9.3")] public void OnUpstreamFinished_should_fail_orphaned_requests() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); var (request1, pending1) = MakeTrackedRequest("/1"); var (request2, pending2) = MakeTrackedRequest("/2"); @@ -461,7 +453,7 @@ public void OnUpstreamFinished_should_fail_orphaned_requests() [Trait("RFC", "RFC9112-6")] public void CanAcceptRequest_should_be_true_initially() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); Assert.True(sm.CanAcceptRequest); @@ -471,7 +463,7 @@ public void CanAcceptRequest_should_be_true_initially() [Trait("RFC", "RFC9112-6")] public void CanAcceptRequest_should_be_false_when_queue_full() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig(maxPipelineDepth: 2)); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); @@ -483,7 +475,7 @@ public void CanAcceptRequest_should_be_false_when_queue_full() [Trait("RFC", "RFC9112-9.3")] public void HasInFlightRequests_should_reflect_queue_count() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); Assert.False(sm.HasInFlightRequests); @@ -495,7 +487,7 @@ public void HasInFlightRequests_should_reflect_queue_count() [Trait("RFC", "RFC9112-6")] public void Endpoint_should_be_initialized_on_first_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); Assert.Equal(default, sm.Endpoint); @@ -507,7 +499,7 @@ public void Endpoint_should_be_initialized_on_first_request() [Trait("RFC", "RFC9112-6")] public void PendingRequestCount_should_reflect_queue_count() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); @@ -519,7 +511,7 @@ public void PendingRequestCount_should_reflect_queue_count() [Trait("RFC", "RFC9112-9.3")] public void IsReconnecting_should_be_false_initially() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); Assert.False(sm.IsReconnecting); @@ -529,7 +521,7 @@ public void IsReconnecting_should_be_false_initially() [Trait("RFC", "RFC9112-6")] public void Cleanup_should_clear_inflight_queue() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); @@ -543,14 +535,14 @@ public void Cleanup_should_clear_inflight_queue() [Trait("RFC", "RFC9112-6")] public void Cleanup_should_dispose_body_owners() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer1 = CreateResponseBuffer("HTTP/1.1 200 OK\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer1)); + sm.DecodeServerData(TransportData.Rent(buffer1)); var buffer2 = CreateResponseBuffer("body"); - sm.DecodeServerData(new TransportData(buffer2)); + sm.DecodeServerData(TransportData.Rent(buffer2)); sm.Cleanup(); @@ -561,7 +553,7 @@ public void Cleanup_should_dispose_body_owners() [Trait("RFC", "RFC9112-9.3")] public void Pipeline_should_correlate_responses_to_requests_in_order() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); @@ -571,7 +563,7 @@ public void Pipeline_should_correlate_responses_to_requests_in_order() "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK" + "HTTP/1.1 201 Created\r\nContent-Length: 7\r\n\r\nCreated" + "HTTP/1.1 202 Accepted\r\nContent-Length: 8\r\n\r\nAccepted"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.Equal(3, ops.Responses.Count); Assert.NotNull(ops.Responses[0].RequestMessage); @@ -583,15 +575,14 @@ public void Pipeline_should_correlate_responses_to_requests_in_order() [Trait("RFC", "RFC9112-9.8")] public void CloseDelimited_should_work_with_initial_body_bytes() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer1 = CreateResponseBuffer("HTTP/1.1 200 OK\r\n\r\nstart"); - sm.DecodeServerData(new TransportData(buffer1)); + sm.DecodeServerData(TransportData.Rent(buffer1)); - var buffer2 = CreateResponseBuffer("more"); - sm.DecodeServerData(new TransportData(buffer2)); + Assert.False(sm.ShouldPauseNetwork); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Graceful)); @@ -603,12 +594,12 @@ public void CloseDelimited_should_work_with_initial_body_bytes() [Trait("RFC", "RFC9112-9.8")] public void NoBodyResponseTypes_should_not_be_close_delimited() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 204 No Content\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.Single(ops.Responses); Assert.Equal((int)System.Net.HttpStatusCode.NoContent, (int)ops.Responses[0].StatusCode); @@ -618,12 +609,12 @@ public void NoBodyResponseTypes_should_not_be_close_delimited() [Trait("RFC", "RFC9112-9.8")] public void Not_Modified_should_not_be_close_delimited() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 304 Not Modified\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.Single(ops.Responses); Assert.Equal((int)System.Net.HttpStatusCode.NotModified, (int)ops.Responses[0].StatusCode); @@ -633,28 +624,29 @@ public void Not_Modified_should_not_be_close_delimited() [Trait("RFC", "RFC9112-9.8")] public void TransferEncoding_chunked_should_not_be_close_delimited() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest()); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); - Assert.Empty(ops.Responses); + Assert.Single(ops.Responses); + Assert.Equal(200, (int)ops.Responses[0].StatusCode); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-6")] public void Multiple_requests_with_connection_close_should_disable_pipeline() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, MakeConfig()); sm.OnRequest(MakeRequest("/1")); sm.OnRequest(MakeRequest("/2")); sm.OnRequest(MakeRequest("/3")); var buffer = CreateResponseBuffer("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); Assert.Single(ops.Responses); var response = ops.Responses[0]; @@ -664,7 +656,7 @@ public void Multiple_requests_with_connection_close_should_disable_pipeline() [Fact(Timeout = 5000)] public void CanAcceptRequest_should_be_false_while_body_pending() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, new TurboClientOptions()); sm.PreStart(); @@ -678,9 +670,9 @@ public void CanAcceptRequest_should_be_false_while_body_pending() } [Fact(Timeout = 5000)] - public void CanAcceptRequest_should_become_true_after_OutboundBodyComplete() + public void CanAcceptRequest_should_become_true_after_body_drain_completes() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http11ClientStateMachine(ops, new TurboClientOptions()); sm.PreStart(); @@ -689,7 +681,7 @@ public void CanAcceptRequest_should_become_true_after_OutboundBodyComplete() Content = new ByteArrayContent(new byte[1000]) }; sm.OnRequest(request); - sm.OnBodyMessage(new OutboundBodyComplete()); + sm.OnBodyMessage(new Http11ClientStateMachine.BodyReadComplete(0)); Assert.True(sm.CanAcceptRequest); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs index 135ceb4fa..e6f2d81d9 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs @@ -1,18 +1,17 @@ using System.Text; -using Akka.Actor; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.RoundTrip; public sealed class Http11RoundTripBodySpec { - private static readonly Http11ClientEncoder Encoder = new(Http11ClientEncoderOptions.Default); + private static readonly Http11ClientEncoder Encoder = new(ClientOptionDefaults.Http11Encoder()); private static int EncodeRequest(HttpRequestMessage request, Span buffer) { - return Encoder.Encode(buffer, request, ActorRefs.Nobody); + return Encoder.Encode(buffer, request, out _, out _); } private static ReadOnlyMemory BuildResponse(int status, string reason, string body, @@ -64,18 +63,18 @@ private static ReadOnlyMemory Combine(params ReadOnlyMemory[] parts) private static List Decode(ReadOnlyMemory data) { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var responses = new List(); - var span = data.Span; - while (span.Length > 0) + var offset = 0; + while (offset < data.Length) { - var outcome = decoder.Feed(span, false, out var consumed); + var outcome = decoder.Feed(data[offset..], false, out var consumed); if (outcome == DecodeOutcome.NeedMore) { break; } - span = span[consumed..]; + offset += consumed; if (outcome == DecodeOutcome.Complete) { responses.Add(decoder.GetResponse()); @@ -236,13 +235,13 @@ public async Task Http11RoundTripBody_should_decode_one_byte_when_content_length [Trait("RFC", "RFC9112-6")] public async Task Http11RoundTripBody_should_decode_after_reset_when_content_length_roundtrip() { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var r1 = BuildResponse(200, "OK", "first", ("Content-Length", "5")); - decoder.Feed(r1.Span, false, out _); + decoder.Feed(r1, false, out _); decoder.Reset(); var r2 = BuildResponse(200, "OK", "second", ("Content-Length", "6")); - var outcome = decoder.Feed(r2.Span, false, out _); + var outcome = decoder.Feed(r2, false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); var response = decoder.GetResponse(); @@ -253,14 +252,14 @@ public async Task Http11RoundTripBody_should_decode_after_reset_when_content_len [Trait("RFC", "RFC9112-6")] public async Task Http11RoundTripBody_should_decode_all_sizes_when_keep_alive_varying_body_sizes() { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var sizes = new[] { 1, 10, 100, 1000 }; foreach (var size in sizes) { var body = new string('A', size); var raw = BuildResponse(200, "OK", body, ("Content-Length", size.ToString())); - var outcome = decoder.Feed(raw.Span, false, out _); + var outcome = decoder.Feed(raw, false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); var response = decoder.GetResponse(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripFragmentationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripFragmentationSpec.cs index bf35e045e..b508ef7af 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripFragmentationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripFragmentationSpec.cs @@ -1,7 +1,7 @@ using System.Text; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.RoundTrip; @@ -19,9 +19,9 @@ public async Task Http11RoundTripFragmentation_should_assemble_response_when_spl var part1 = new ReadOnlyMemory(bytes, 0, splitAt); var part2 = new ReadOnlyMemory(bytes, splitAt, bytes.Length - splitAt); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome1 = decoder.Feed(part1.Span, false, out _); - var outcome2 = decoder.Feed(part2.Span, false, out _); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome1 = decoder.Feed(part1, false, out _); + var outcome2 = decoder.Feed(part2, false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome1); Assert.Equal(DecodeOutcome.Complete, outcome2); @@ -36,9 +36,9 @@ public async Task Http11RoundTripFragmentation_should_assemble_response_when_spl var headerBytes = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n"u8.ToArray(); var bodyBytes = "hello"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome1 = decoder.Feed(headerBytes.AsSpan(), false, out _); - var outcome2 = decoder.Feed(bodyBytes.AsSpan(), false, out _); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome1 = decoder.Feed(headerBytes.AsMemory(), false, out _); + var outcome2 = decoder.Feed(bodyBytes.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome1); Assert.Equal(DecodeOutcome.Complete, outcome2); @@ -59,9 +59,9 @@ public async Task Http11RoundTripFragmentation_should_assemble_body_when_split_m var part1 = new ReadOnlyMemory(bytes, 0, splitAt); var part2 = new ReadOnlyMemory(bytes, splitAt, bytes.Length - splitAt); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome1 = decoder.Feed(part1.Span, false, out _); - var outcome2 = decoder.Feed(part2.Span, false, out _); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome1 = decoder.Feed(part1, false, out _); + var outcome2 = decoder.Feed(part2, false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome1); Assert.Equal(DecodeOutcome.Complete, outcome2); @@ -78,7 +78,7 @@ public async Task Http11RoundTripFragmentation_should_assemble_response_when_sin // The decoder does not buffer internally between calls, so callers must accumulate // unconsumed bytes and re-feed from the start of any incomplete parse unit. - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var accum = new byte[bytes.Length]; var accumLen = 0; HttpResponseMessage? finalResponse = null; @@ -86,7 +86,7 @@ public async Task Http11RoundTripFragmentation_should_assemble_response_when_sin for (var i = 0; i < bytes.Length; i++) { accum[accumLen++] = bytes[i]; - var outcome = decoder.Feed(accum.AsSpan(0, accumLen), false, out var consumed); + var outcome = decoder.Feed(accum.AsMemory(0, accumLen), false, out var consumed); if (consumed > 0) { accum.AsSpan(consumed, accumLen - consumed).CopyTo(accum); @@ -111,13 +111,19 @@ public async Task Http11RoundTripFragmentation_should_assemble_chunked_body_when (ReadOnlyMemory)"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\n"u8.ToArray(); var part2 = (ReadOnlyMemory)"3\r\nbar\r\n0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome1 = decoder.Feed(part1.Span, false, out _); - var outcome2 = decoder.Feed(part2.Span, false, out _); - + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome1 = decoder.Feed(part1, false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome1); - Assert.Equal(DecodeOutcome.Complete, outcome2); + var response = decoder.GetResponse(); - Assert.Equal("foobar", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var bodyStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken); + var buf = new byte[64]; + var read1 = await bodyStream.ReadAsync(buf, TestContext.Current.CancellationToken); + Assert.Equal("foo", Encoding.ASCII.GetString(buf, 0, read1)); + + var outcome2 = decoder.Feed(part2, false, out _); + Assert.Equal(DecodeOutcome.Complete, outcome2); + var read2 = await bodyStream.ReadAsync(buf, TestContext.Current.CancellationToken); + Assert.Equal("bar", Encoding.ASCII.GetString(buf, 0, read2)); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs index 03d0c02f0..a93939ac1 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs @@ -1,19 +1,18 @@ using System.Net; using System.Text; -using Akka.Actor; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.RoundTrip; public sealed class Http11RoundTripMethodSpec { - private static readonly Http11ClientEncoder Encoder = new(Http11ClientEncoderOptions.Default); + private static readonly Http11ClientEncoder Encoder = new(ClientOptionDefaults.Http11Encoder()); private static int EncodeRequest(HttpRequestMessage request, Span buffer) { - return Encoder.Encode(buffer, request, ActorRefs.Nobody); + return Encoder.Encode(buffer, request, out _, out _); } private static ReadOnlyMemory BuildResponse(int status, string reason, string body, @@ -33,8 +32,8 @@ private static ReadOnlyMemory BuildResponse(int status, string reason, str private static HttpResponseMessage Decode(ReadOnlyMemory data) { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome = decoder.Feed(data.Span, false, out _); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome = decoder.Feed(data, false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); return decoder.GetResponse(); } @@ -149,11 +148,11 @@ public void Http11RoundTrip_should_return_content_length_header_when_head_round_ var encoded = Encoding.ASCII.GetString(buf, 0, written); Assert.StartsWith("HEAD /resource HTTP/1.1", encoded); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var raw = BuildResponse(200, "OK", "", ("Content-Length", "0"), ("Content-Type", "application/octet-stream")); - var outcome = decoder.Feed(raw.Span, true, out _); + var outcome = decoder.Feed(raw, true, out _); Assert.Equal(DecodeOutcome.Complete, outcome); var response = decoder.GetResponse(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripNoBodySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripNoBodySpec.cs index b353918e2..4c05c697e 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripNoBodySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripNoBodySpec.cs @@ -2,7 +2,7 @@ using System.Text; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.RoundTrip; @@ -39,18 +39,18 @@ private static ReadOnlyMemory Combine(params ReadOnlyMemory[] parts) private static List Decode(ReadOnlyMemory data, bool isHead = false) { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var responses = new List(); - var span = data.Span; - while (span.Length > 0) + var offset = 0; + while (offset < data.Length) { - var outcome = decoder.Feed(span, isHead, out var consumed); + var outcome = decoder.Feed(data[offset..], isHead, out var consumed); if (outcome == DecodeOutcome.NeedMore) { break; } - span = span[consumed..]; + offset += consumed; if (outcome == DecodeOutcome.Complete) { responses.Add(decoder.GetResponse()); @@ -187,18 +187,18 @@ public async Task Http11RoundTrip_should_decode_both_heads_when_two_head_respons [Trait("RFC", "RFC9112-6")] public async Task Http11RoundTrip_should_decode_get_after_head_when_same_decoder_used_for_both() { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); const string headRaw = "HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n"; var headBytes = Encoding.ASCII.GetBytes(headRaw); - var outcome1 = decoder.Feed(headBytes.AsSpan(), true, out _); + var outcome1 = decoder.Feed(headBytes.AsMemory(), true, out _); Assert.Equal(DecodeOutcome.Complete, outcome1); var headResp = decoder.GetResponse(); decoder.Reset(); Assert.Empty(await headResp.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken)); var getRaw = BuildResponse(200, "OK", "actual body", ("Content-Length", "11")); - var outcome2 = decoder.Feed(getRaw.Span, false, out _); + var outcome2 = decoder.Feed(getRaw, false, out _); Assert.Equal(DecodeOutcome.Complete, outcome2); var getResp = decoder.GetResponse(); Assert.Equal("actual body", await getResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripStatusCodeSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripStatusCodeSpec.cs index 4af30311f..8bbf79497 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripStatusCodeSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripStatusCodeSpec.cs @@ -2,7 +2,7 @@ using System.Text; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.RoundTrip; @@ -25,8 +25,8 @@ private static ReadOnlyMemory BuildResponse(int status, string reason, str private static HttpResponseMessage Decode(ReadOnlyMemory data) { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome = decoder.Feed(data.Span, false, out _); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome = decoder.Feed(data, false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); return decoder.GetResponse(); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11FuzzBodySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11FuzzBodySpec.cs index a9676d1f6..80e2cc0e1 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11FuzzBodySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11FuzzBodySpec.cs @@ -2,6 +2,7 @@ using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Security; @@ -9,13 +10,13 @@ public sealed class Http11FuzzBodySpec { private const int IterationsPerSeed = 100; private const long MaxBytesPerIteration = 1_048_576; - private static readonly Http11ClientDecoderOptions DecoderOptions = Http11ClientDecoderOptions.Default; + private static readonly Http11ClientDecoderOptions DecoderOptions = ClientOptionDefaults.Http11Decoder(); private static void AssertDecodeNeverCrashes(Http11ClientDecoder decoder, ReadOnlyMemory data) { try { - var outcome = decoder.Feed(data.Span, requestMethodWasHead: false, out _); + var outcome = decoder.Feed(data, requestMethodWasHead: false, out _); if (outcome == DecodeOutcome.Complete) { var response = decoder.GetResponse(); @@ -29,6 +30,14 @@ private static void AssertDecodeNeverCrashes(Http11ClientDecoder decoder, ReadOn catch (FormatException) { } + catch (InvalidOperationException) + { + // QueuedBodyReader.TryEnqueue returns false when all slots are occupied. + // In production this cannot occur: Akka back-pressure ensures each slot is + // consumed before the next enqueue. In synchronous fuzz delivery without + // a stream consumer, this is an expected violation of the back-pressure contract. + decoder.Reset(); + } } private static void AssertDecodeEofNeverCrashes(Http11ClientDecoder decoder) @@ -48,6 +57,13 @@ private static void AssertDecodeEofNeverCrashes(Http11ClientDecoder decoder) catch (FormatException) { } + catch (InvalidOperationException) + { + // Same back-pressure contract violation as in AssertDecodeNeverCrashes. + // SignalEof calls reader.Complete() which hits SetResult on an already-pending + // ManualResetValueTaskSourceCore. Only possible when stream is not consumed. + decoder.Reset(); + } } private static byte[] BuildValidResponse(int statusCode, string reason, string body, diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11NegativePathSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11NegativePathSpec.cs index 8a9f562e6..1acd078b8 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11NegativePathSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11NegativePathSpec.cs @@ -1,7 +1,7 @@ using System.Text; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Security; @@ -9,18 +9,18 @@ public sealed class Http11NegativePathSpec { private static List Decode(ReadOnlyMemory data, bool isHead = false) { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); var responses = new List(); - var span = data.Span; - while (span.Length > 0) + var offset = 0; + while (offset < data.Length) { - var outcome = decoder.Feed(span, isHead, out var consumed); + var outcome = decoder.Feed(data[offset..], isHead, out var consumed); if (outcome == DecodeOutcome.NeedMore) { break; } - span = span[consumed..]; + offset += consumed; if (outcome == DecodeOutcome.Complete) { responses.Add(decoder.GetResponse()); @@ -36,9 +36,9 @@ private static List Decode(ReadOnlyMemory data, bool public void Http11NegativePath_should_parse_http20_version() { var raw = "HTTP/2.0 200 OK\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); var resp = decoder.GetResponse(); @@ -51,9 +51,9 @@ public void Http11NegativePath_should_treat_non_http_protocol_as_http09() { // "HTTPS/1.1" does not start with "HTTP/", so the decoder treats it as HTTP/0.9 body data. var raw = "HTTPS/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.NotEqual(DecodeOutcome.Complete, outcome); } @@ -65,9 +65,9 @@ public void Http11NegativePath_should_need_more_when_double_space_before_status_ // RFC 9112 §4: exactly one SP between HTTP-version and 3-digit status code. // The parser returns false (NeedMore) for a malformed status line. var raw = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome); } @@ -79,9 +79,9 @@ public void Http11NegativePath_should_need_more_when_two_digit_status_code() // RFC 9112 §4: status-code is exactly 3 decimal digits. // The parser returns false (NeedMore) for a malformed status line. var raw = "HTTP/1.1 20 OK\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome); } @@ -92,9 +92,9 @@ public void Http11NegativePath_should_need_more_when_non_digit_in_status_code() { // The parser returns false (NeedMore) for a malformed status-code. var raw = "HTTP/1.1 20A OK\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome); } @@ -106,9 +106,9 @@ public void Http11NegativePath_should_never_decode_when_bare_line_feed_in_status // RFC 9112 §2.2: a recipient MUST NOT treat a bare LF as a line terminator. // Bare-LF input is treated as incomplete data (NeedMore). var raw = "HTTP/1.1 200 OK\nContent-Length: 0\n\n"u8.ToArray(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome); } @@ -121,9 +121,9 @@ public void Http11NegativePath_should_decode_when_overlong_reason_phrase() // it reads to CRLF. Only header-block bytes count toward MaxHeaderBytes. var longReason = new string('X', 66000); var raw = Encoding.ASCII.GetBytes($"HTTP/1.1 200 {longReason}\r\nContent-Length: 0\r\n\r\n"); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); } @@ -180,9 +180,9 @@ public void Http11NegativePath_should_need_more_when_non_chunked_te_without_cont "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: gzip\r\n" + "\r\n"); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - var outcome = decoder.Feed(raw.AsSpan(), false, out _); + var outcome = decoder.Feed(raw.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome); } @@ -270,9 +270,9 @@ public void Http11NegativePath_should_reject_when_multiple_content_length_differ "\r\n" + "Hello"; var raw = Encoding.ASCII.GetBytes(response); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.AsSpan(), false, out _)); + Assert.Throws(() => decoder.Feed(raw.AsMemory(), false, out _)); } [Fact(Timeout = 5000)] @@ -287,9 +287,9 @@ public void Http11NegativePath_should_reject_when_transfer_encoding_and_content_ "\r\n" + "5\r\nHello\r\n0\r\n\r\n"; var raw = Encoding.ASCII.GetBytes(response); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.AsSpan(), false, out _)); + Assert.Throws(() => decoder.Feed(raw.AsMemory(), false, out _)); } [Fact(Timeout = 5000)] @@ -304,9 +304,9 @@ public void Http11NegativePath_should_reject_when_chunked_zero_size_non_numeric_ "0x5\r\nHello\r\n" + "0\r\n\r\n"; var raw = Encoding.ASCII.GetBytes(response); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.AsSpan(), false, out _)); + Assert.Throws(() => decoder.Feed(raw.AsMemory(), false, out _)); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11SecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11SecuritySpec.cs index 9c3f7a88c..2a04b3139 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11SecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Security/Http11SecuritySpec.cs @@ -1,7 +1,7 @@ using System.Text; using TurboHTTP.Protocol.Syntax; using TurboHTTP.Protocol.Syntax.Http11.Client; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Security; @@ -13,8 +13,8 @@ public void Http11Security_should_accept_100_headers_when_at_default_limit() { // Default MaxHeaderCount = 100; 99 extra + Content-Length = 100 total var raw = BuildResponseWithNHeaders(99); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome = decoder.Feed(raw.Span, false, out _); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome = decoder.Feed(raw, false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); } @@ -25,9 +25,9 @@ public void Http11Security_should_reject_101_headers_when_above_default_limit() { // 100 extra + Content-Length = 101 total, exceeds default MaxHeaderCount = 100 var raw = BuildResponseWithNHeaders(100); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.Span, false, out _)); + Assert.Throws(() => decoder.Feed(raw, false, out _)); } [Fact(Timeout = 5000)] @@ -36,10 +36,10 @@ public void Http11Security_should_reject_at_custom_limit_when_header_count_excee { // 5 extra + Content-Length = 6 total, exceeds custom MaxHeaderCount = 5 var raw = BuildResponseWithNHeaders(5); - var opts = new Http11ClientDecoderOptions { Shared = SharedHttpOptions.Default with { MaxHeaderCount = 5 } }; + var opts = ClientOptionDefaults.Http11Decoder() with { MaxHeaderCount = 5 }; var decoder = new Http11ClientDecoder(opts); - Assert.Throws(() => decoder.Feed(raw.Span, false, out _)); + Assert.Throws(() => decoder.Feed(raw, false, out _)); } [Fact(Timeout = 5000)] @@ -48,8 +48,8 @@ public void Http11Security_should_accept_header_block_when_below_total_header_li { // Build a response with ~8KB of headers, well below the 32KB MaxHeaderBytes default var raw = BuildResponseWithLargeHeader(8191); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); - var outcome = decoder.Feed(raw.Span, false, out _); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); + var outcome = decoder.Feed(raw, false, out _); Assert.Equal(DecodeOutcome.Complete, outcome); } @@ -60,9 +60,9 @@ public void Http11Security_should_reject_header_block_when_above_total_header_li { // Build a response with headers exceeding MaxHeaderBytes (32KB default) var raw = BuildResponseWithLargeHeader(33000); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.Span, false, out _)); + Assert.Throws(() => decoder.Feed(raw, false, out _)); } [Fact(Timeout = 5000)] @@ -71,9 +71,9 @@ public void Http11Security_should_reject_single_header_when_value_exceeds_limit( { // 17000 bytes exceeds the default HeaderLineMaxLength (8KB) var raw = BuildResponseWithLargeHeaderValue(17000); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.Span, false, out _)); + Assert.Throws(() => decoder.Feed(raw, false, out _)); } [Fact(Timeout = 5000)] @@ -81,9 +81,9 @@ public void Http11Security_should_reject_single_header_when_value_exceeds_limit( public void Http11Security_should_reject_response_when_both_transfer_encoding_and_content_length_present() { var raw = BuildResponseWithTeAndCl(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.Span, false, out _)); + Assert.Throws(() => decoder.Feed(raw, false, out _)); } [Fact(Timeout = 5000)] @@ -91,9 +91,9 @@ public void Http11Security_should_reject_response_when_both_transfer_encoding_an public void Http11Security_should_reject_header_when_crlf_injected_in_value() { var raw = BuildResponseWithBareCrInHeaderValue(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.Span, false, out _)); + Assert.Throws(() => decoder.Feed(raw, false, out _)); } [Fact(Timeout = 5000)] @@ -101,20 +101,20 @@ public void Http11Security_should_reject_header_when_crlf_injected_in_value() public void Http11Security_should_reject_header_when_nul_byte_in_value() { var raw = BuildResponseWithNulInHeaderValue(); - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); - Assert.Throws(() => decoder.Feed(raw.Span, false, out _)); + Assert.Throws(() => decoder.Feed(raw, false, out _)); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-11")] public void Http11Security_should_decode_cleanly_when_reset_after_partial_headers() { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); // Feed incomplete headers (no CRLFCRLF yet) var incomplete = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n"u8.ToArray(); - var outcome1 = decoder.Feed(incomplete.AsSpan(), false, out _); + var outcome1 = decoder.Feed(incomplete.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome1); // Reset clears remainder @@ -122,7 +122,7 @@ public void Http11Security_should_decode_cleanly_when_reset_after_partial_header // Feed a complete valid response — decoder must behave as if fresh var complete = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello"u8.ToArray(); - var outcome2 = decoder.Feed(complete.AsSpan(), false, out _); + var outcome2 = decoder.Feed(complete.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.Complete, outcome2); } @@ -131,11 +131,11 @@ public void Http11Security_should_decode_cleanly_when_reset_after_partial_header [Trait("RFC", "RFC9112-11")] public void Http11Security_should_decode_cleanly_when_reset_after_partial_body() { - var decoder = new Http11ClientDecoder(Http11ClientDecoderOptions.Default); + var decoder = new Http11ClientDecoder(ClientOptionDefaults.Http11Decoder()); // Feed headers + partial body (body says 10 bytes but we only send 5) var partial = "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nHello"u8.ToArray(); - var outcome1 = decoder.Feed(partial.AsSpan(), false, out _); + var outcome1 = decoder.Feed(partial.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.NeedMore, outcome1); // Reset discards the partial state @@ -143,7 +143,7 @@ public void Http11Security_should_decode_cleanly_when_reset_after_partial_body() // Feed a complete valid response var complete = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nWorld"u8.ToArray(); - var outcome2 = decoder.Feed(complete.AsSpan(), false, out _); + var outcome2 = decoder.Feed(complete.AsMemory(), false, out _); Assert.Equal(DecodeOutcome.Complete, outcome2); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11DataRateSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11DataRateSpec.cs new file mode 100644 index 000000000..97bce107f --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11DataRateSpec.cs @@ -0,0 +1,250 @@ +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Time.Testing; +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http11.Server; +using TurboHTTP.Server; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Tests.Shared; +using static TurboHTTP.Protocol.Syntax.Http11.Server.Http11ServerStateMachine; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; + +public sealed class Http11DataRateSpec +{ + private static TurboFeatureCollection CreateResponseContext() + { + var features = new TurboFeatureCollection(); + features.Set(new TurboHttpRequestFeature()); + features.Set(new TurboHttpResponseFeature { StatusCode = 200 }); + var bodyFeature = new TurboHttpResponseBodyFeature(); + features.Set(bodyFeature); + features.Set(bodyFeature); + return features; + } + + private static TransportBuffer MakeBuffer(string raw) + { + var data = Encoding.ASCII.GetBytes(raw); + var buffer = TransportBuffer.Rent(data.Length); + data.CopyTo(buffer.FullMemory.Span); + buffer.Length = data.Length; + return buffer; + } + + private static Http1ConnectionOptions CreateOptionsWithResponseRate(double minRate, TimeSpan grace) + { + var defaultOptions = new TurboServerOptions().ToHttp1Options(); + var newLimits = defaultOptions.Limits with + { + MinResponseDataRate = minRate, + MinResponseDataRateGracePeriod = grace + }; + return defaultOptions with { Limits = newLimits }; + } + + private static Http1ConnectionOptions CreateOptionsWithRequestRate(double minRate, TimeSpan grace) + { + var defaultOptions = new TurboServerOptions().ToHttp1Options(); + var newLimits = defaultOptions.Limits with + { + MinRequestBodyDataRate = minRate, + MinRequestBodyDataRateGracePeriod = grace + }; + return defaultOptions with { Limits = newLimits }; + } + + [Fact(Timeout = 5000)] + public void Slow_request_body_violation_sets_should_complete_with_injected_clock() + { + var options = CreateOptionsWithRequestRate(1000, TimeSpan.FromSeconds(1)); + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(options, new TurboServerOptions().ToHttp2Options(), ops, clock); + + // Chunked request body forces streaming (small Content-Length bodies are buffered, not observed). + // One small chunk arrives, then the upload stalls without the terminating chunk. + var headersAndPartialChunk = "POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nAAAAA\r\n"; + sm.DecodeClientData(TransportData.Rent(MakeBuffer(headersAndPartialChunk))); + + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete, "Should be in grace period at first check"); + + // 5 bytes in 1700ms = ~2.9 bytes/sec << 1000, grace (1s) expired → violation. + clock.Advance(TimeSpan.FromMilliseconds(1100)); + sm.OnTimerFired("data-rate-check"); + Assert.True(sm.ShouldComplete, "Expected request body data rate violation after grace expires"); + } + + [Fact(Timeout = 5000)] + public void Data_rate_monitoring_disabled_by_default() + { + var defaultOptions = new TurboServerOptions().ToHttp1Options(); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(defaultOptions, new TurboServerOptions().ToHttp2Options(), ops); + + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); + + // Fire timer with monitoring disabled — should not schedule another timer + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Fast_response_body_should_not_violate() + { + var options = CreateOptionsWithResponseRate(100, TimeSpan.FromSeconds(1)); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(options, new TurboServerOptions().ToHttp2Options(), ops); + + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + // Send large response body quickly (exceeds minimum rate) + sm.OnBodyMessage(new ResponseBodyReadComplete(5000)); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Idle_connection_should_not_be_flagged() + { + var options = CreateOptionsWithResponseRate(10000, TimeSpan.FromSeconds(1)); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(options, new TurboServerOptions().ToHttp2Options(), ops); + + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Response_body_rate_within_grace_period_should_not_violate() + { + var options = CreateOptionsWithResponseRate(1000, TimeSpan.FromSeconds(5)); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(options, new TurboServerOptions().ToHttp2Options(), ops); + + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + sm.OnBodyMessage(new ResponseBodyReadComplete(10)); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public async Task Response_completion_should_remove_rate_tracking() + { + var options = CreateOptionsWithResponseRate(10000, TimeSpan.FromMilliseconds(100)); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(options, new TurboServerOptions().ToHttp2Options(), ops); + + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + sm.OnBodyMessage(new ResponseBodyReadComplete(1)); + + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); + + await Task.Delay(150, TestContext.Current.CancellationToken); + + sm.OnTimerFired("data-rate-check"); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + public void Slow_response_body_violation_sets_should_complete_with_injected_clock() + { + var options = CreateOptionsWithResponseRate(1000, TimeSpan.FromSeconds(1)); + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(options, new TurboServerOptions().ToHttp2Options(), ops, clock); + + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + // Feed tiny amount of response body (will be observed at time=0) + sm.OnBodyMessage(new ResponseBodyReadComplete(10)); + + // Advance clock to first check point (600ms, triggers first rate calculation but still in grace) + // With 10 bytes in 600ms = 16.67 bytes/sec < 1000 bytes/sec, enters grace period + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete, "Should be in grace period at first check"); + + // Advance clock past grace period (1100ms total, and grace started at 600ms) + // Now > GracePeriodStart (600) + 1000ms grace = 1600ms, so should violate + clock.Advance(TimeSpan.FromMilliseconds(1100)); + sm.OnTimerFired("data-rate-check"); + Assert.True(sm.ShouldComplete, "Expected data rate violation to set ShouldComplete after grace expires"); + } + + [Fact(Timeout = 5000)] + public void Fast_response_body_within_grace_should_not_violate_with_injected_clock() + { + var options = CreateOptionsWithResponseRate(1000, TimeSpan.FromSeconds(5)); + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(options, new TurboServerOptions().ToHttp2Options(), ops, clock); + + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + var headerBuffer = MakeBuffer(requestData); + sm.DecodeClientData(TransportData.Rent(headerBuffer)); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + // Feed tiny amount at time=0 + sm.OnBodyMessage(new ResponseBodyReadComplete(10)); + + // Check at time=600ms (first rate check, enters grace) + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete); + + // Check at time=3600ms (within 5s grace period from 600ms = 5600ms) — should still be OK + clock.Advance(TimeSpan.FromMilliseconds(3000)); + sm.OnTimerFired("data-rate-check"); + Assert.False(sm.ShouldComplete, "Should not abort when within grace period"); + } +} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBodyBackpressureSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBodyBackpressureSpec.cs new file mode 100644 index 000000000..55ac75b8f --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBodyBackpressureSpec.cs @@ -0,0 +1,130 @@ +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http11.Server; +using TurboHTTP.Server; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Tests.Shared; +using static TurboHTTP.Protocol.Syntax.Http11.Server.Http11ServerStateMachine; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; + +/// +/// Tests for the PipeTo-based response body flow. PipeTo is inherently sequential +/// (one read at a time), so explicit watermark-based pause/resume is no longer needed. +/// These tests verify the ResponseBodyReadComplete/Failed message handling. +/// +public sealed class Http11ServerBodyBackpressureSpec +{ + private static IFeatureCollection CreateResponseContext() + { + var features = new TurboFeatureCollection(); + features.Set(new TurboHttpRequestFeature()); + features.Set(new TurboHttpResponseFeature { StatusCode = 200 }); + var bodyFeature = new TurboHttpResponseBodyFeature(); + features.Set(bodyFeature); + return features; + } + + private static TransportBuffer MakeBuffer(string raw) + { + var data = Encoding.ASCII.GetBytes(raw); + var buffer = TransportBuffer.Rent(data.Length); + data.CopyTo(buffer.FullMemory.Span); + buffer.Length = data.Length; + return buffer; + } + + private static Http11ServerStateMachine CreateSm(FakeServerOps ops) + { + return new Http11ServerStateMachine( + new TurboServerOptions().ToHttp1Options(), + new TurboServerOptions().ToHttp2Options(), + ops); + } + + private static void SendRequest(Http11ServerStateMachine sm) + { + const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + sm.DecodeClientData(TransportData.Rent(MakeBuffer(requestData))); + } + + [Fact(Timeout = 5000)] + public void OnBodyMessage_should_emit_transport_data_for_each_read_completion() + { + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + SendRequest(sm); + + var context = CreateResponseContext(); + sm.OnResponse(context); + var headerCount = ops.Outbound.Count; + + // Simulate multiple PipeTo read completions + sm.OnBodyMessage(new ResponseBodyReadComplete(100)); + sm.OnBodyMessage(new ResponseBodyReadComplete(200)); + sm.OnBodyMessage(new ResponseBodyReadComplete(50)); + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); + + // 3 data chunks + 1 chunked terminator from CompleteAsync + var bodyItems = ops.Outbound.Skip(headerCount).OfType().ToList(); + Assert.Equal(4, bodyItems.Count); + } + + [Fact(Timeout = 5000)] + public void OnBodyMessage_complete_should_clear_outbound_pending_flag() + { + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + SendRequest(sm); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + // Body is pending after OnResponse + Assert.False(sm.CanAcceptResponse); + + sm.OnBodyMessage(new ResponseBodyReadComplete(10)); + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); + + // After body complete, outbound pending is cleared + // (CanAcceptResponse is still false because _pendingResponseCount == 0) + Assert.False(sm.CanAcceptResponse); + } + + [Fact(Timeout = 5000)] + public void OnBodyMessage_failed_should_clear_outbound_pending_flag() + { + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + SendRequest(sm); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + sm.OnBodyMessage(new ResponseBodyReadComplete(10)); + sm.OnBodyMessage(new ResponseBodyReadFailed(new Exception("simulated failure"))); + + // Subsequent operations should not throw + sm.OnOutboundFlushed(); + Assert.True(true); + } + + [Fact(Timeout = 5000)] + public void OnOutboundFlushed_should_be_no_op_after_body_complete() + { + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + SendRequest(sm); + + var context = CreateResponseContext(); + sm.OnResponse(context); + + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); + + // PipeTo flow has no watermarks — OnOutboundFlushed is a no-op + sm.OnOutboundFlushed(); + sm.OnOutboundFlushed(); + Assert.True(true); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBodyDrainingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBodyDrainingSpec.cs index e913a6bcb..fd446a745 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBodyDrainingSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBodyDrainingSpec.cs @@ -1,6 +1,4 @@ -using System.Buffers; using System.Text; -using TurboHTTP.Protocol.LineBased.Body; using TurboHTTP.Protocol.Syntax.Http11.Options; using TurboHTTP.Protocol.Syntax.Http11.Server; @@ -8,195 +6,39 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; public sealed class Http11ServerBodyDrainingSpec { - [Fact(Timeout = 5000)] - public void ContentLengthBufferedDecoder_IsComplete_should_return_true_when_all_bytes_received() - { - var decoder = new ContentLengthBufferedDecoder(10, MemoryPool.Shared); - - var data = "0123456789"u8.ToArray(); - decoder.Feed(data, out _); - - Assert.True(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ContentLengthBufferedDecoder_IsComplete_should_return_false_when_incomplete() - { - var decoder = new ContentLengthBufferedDecoder(10, MemoryPool.Shared); - - var data = "01234"u8.ToArray(); - decoder.Feed(data, out _); - - Assert.False(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ContentLengthBufferedDecoder_Drain_should_skip_remaining_bytes() - { - var decoder = new ContentLengthBufferedDecoder(10, MemoryPool.Shared); - - var data = "012"u8.ToArray(); - decoder.Feed(data, out _); - Assert.False(decoder.IsComplete); - - var remaining = "3456789"u8.ToArray(); - var drained = decoder.Drain(remaining); - - Assert.Equal(7, drained); - Assert.True(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ContentLengthBufferedDecoder_Drain_should_return_zero_when_complete() - { - var decoder = new ContentLengthBufferedDecoder(5, MemoryPool.Shared); - - var data = "01234"u8.ToArray(); - decoder.Feed(data, out _); - Assert.True(decoder.IsComplete); - - var drained = decoder.Drain("extra"u8); - - Assert.Equal(0, drained); - } - - [Fact(Timeout = 5000)] - public void ContentLengthBufferedDecoder_Drain_should_consume_only_needed_bytes() + private static Http11ServerDecoderOptions DefaultDecoderOptions() => new() { - var decoder = new ContentLengthBufferedDecoder(10, MemoryPool.Shared); - - var data = "01234"u8.ToArray(); - decoder.Feed(data, out _); - - var remaining = "567890extra"u8.ToArray(); - var drained = decoder.Drain(remaining); - - Assert.Equal(5, drained); - Assert.True(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ContentLengthStreamedDecoder_IsComplete_should_return_true_when_all_bytes_received() - { - var decoder = new ContentLengthStreamedDecoder(10); - - var data = "0123456789"u8.ToArray(); - decoder.Feed(data, out _); - - Assert.True(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ContentLengthStreamedDecoder_IsComplete_should_return_false_when_incomplete() - { - var decoder = new ContentLengthStreamedDecoder(10); - - var data = "01234"u8.ToArray(); - decoder.Feed(data, out _); - - Assert.False(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ContentLengthStreamedDecoder_Drain_should_skip_remaining_bytes() - { - var decoder = new ContentLengthStreamedDecoder(10); - - var data = "012"u8.ToArray(); - decoder.Feed(data, out _); - Assert.False(decoder.IsComplete); - - var remaining = "3456789"u8.ToArray(); - var drained = decoder.Drain(remaining); - - Assert.Equal(7, drained); - Assert.True(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ContentLengthStreamedDecoder_Drain_should_return_zero_when_complete() - { - var decoder = new ContentLengthStreamedDecoder(5); - - var data = "01234"u8.ToArray(); - decoder.Feed(data, out _); - Assert.True(decoder.IsComplete); - - var drained = decoder.Drain("extra"u8); - - Assert.Equal(0, drained); - } - - [Fact(Timeout = 5000)] - public void ChunkedBodyDecoder_IsComplete_should_return_true_when_chunk_stream_complete() - { - var decoder = new ChunkedBodyDecoder(); - - var chunks = "5\r\nhello\r\n0\r\n\r\n"u8; - decoder.Feed(chunks, out _); - - Assert.True(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ChunkedBodyDecoder_IsComplete_should_return_false_when_incomplete() - { - var decoder = new ChunkedBodyDecoder(); - - var chunks = "5\r\nhello"u8; - decoder.Feed(chunks, out _); - - Assert.False(decoder.IsComplete); - } - - [Fact(Timeout = 5000)] - public void ChunkedBodyDecoder_Drain_should_parse_and_skip_remaining_chunks() - { - var decoder = new ChunkedBodyDecoder(); - - var partial = "5\r\nhello\r\n"u8; - decoder.Feed(partial, out _); - Assert.False(decoder.IsComplete); - - var remaining = "5\r\nworld\r\n0\r\n\r\n"u8; - var drained = decoder.Drain(remaining); - - Assert.True(decoder.IsComplete); - Assert.True(drained > 0); - } - - [Fact(Timeout = 5000)] - public void ChunkedBodyDecoder_Drain_should_return_zero_when_complete() - { - var decoder = new ChunkedBodyDecoder(); - - var chunks = "5\r\nhello\r\n0\r\n\r\n"u8; - decoder.Feed(chunks, out _); - Assert.True(decoder.IsComplete); - - var drained = decoder.Drain("extra"u8); - - Assert.Equal(0, drained); - } + MaxPipelinedRequests = 10, + MaxChunkExtensionLength = 4 * 1024, + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 4 * 1024 * 1024, + MaxStreamedBodySize = null, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + HeaderLineMaxLength = 8 * 1024, + RequestLineMaxLength = 8 * 1024, + MaxRequestTargetLength = 8 * 1024, + AllowObsFold = false + }; [Fact(Timeout = 5000)] - public void Http11ServerStateMachine_should_expose_current_body_decoder() + public void Http11ServerStateMachine_should_expose_current_body_reader() { - var decoder = new Http11ServerDecoder(Http11ServerDecoderOptions.Default); + var decoder = new Http11ServerDecoder(DefaultDecoderOptions()); const string request = "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nhello"; var bytes = Encoding.ASCII.GetBytes(request); decoder.Feed(bytes, out _); - Assert.NotNull(decoder.CurrentBodyDecoder); - Assert.True(decoder.CurrentBodyDecoder.IsComplete); + Assert.NotNull(decoder.CurrentBodyReader); + Assert.True(decoder.CurrentBodyReader.IsCompleted); } [Fact(Timeout = 5000)] - public void Http11ServerStateMachine_should_expose_null_body_decoder_when_reset() + public void Http11ServerStateMachine_should_expose_null_body_reader_when_reset() { - var decoder = new Http11ServerDecoder(Http11ServerDecoderOptions.Default); + var decoder = new Http11ServerDecoder(DefaultDecoderOptions()); const string request = "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nhello"; var bytes = Encoding.ASCII.GetBytes(request); @@ -204,6 +46,6 @@ public void Http11ServerStateMachine_should_expose_null_body_decoder_when_reset( decoder.Feed(bytes, out _); decoder.Reset(); - Assert.Null(decoder.CurrentBodyDecoder); + Assert.Null(decoder.CurrentBodyReader); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerConnectionPersistenceSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerConnectionPersistenceSpec.cs index 000e1905e..6d23d872c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerConnectionPersistenceSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerConnectionPersistenceSpec.cs @@ -26,10 +26,10 @@ private static IFeatureCollection CreateResponseContext() public void ServerStateMachine_should_default_to_persistent_connection_for_http11() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var buffer = MakeBuffer("GET / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.False(sm.ShouldComplete); } @@ -39,10 +39,10 @@ public void ServerStateMachine_should_default_to_persistent_connection_for_http1 public void ServerStateMachine_should_close_connection_after_http10_request() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var buffer = MakeBuffer("GET / HTTP/1.0\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); } @@ -52,11 +52,11 @@ public void ServerStateMachine_should_close_connection_after_http10_request() public void ServerStateMachine_should_close_connection_when_connection_close_header() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var buffer = MakeBuffer("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); } @@ -66,10 +66,10 @@ public void ServerStateMachine_should_close_connection_when_connection_close_hea public void ServerStateMachine_should_track_pending_requests_via_can_accept_response() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var buffer = MakeBuffer("GET / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.CanAcceptResponse); } @@ -79,10 +79,10 @@ public void ServerStateMachine_should_track_pending_requests_via_can_accept_resp public void ServerStateMachine_should_inject_connection_close_when_flagged() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var buffer = MakeBuffer("GET / HTTP/1.0\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var context = CreateResponseContext(); sm.OnResponse(context); @@ -97,10 +97,10 @@ public void ServerStateMachine_should_inject_connection_close_when_flagged() public void ServerStateMachine_should_clear_pending_requests_on_cleanup() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var buffer = MakeBuffer("GET / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.CanAcceptResponse); sm.Cleanup(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs index 3bdcf51f4..1f6e7475f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs @@ -7,12 +7,24 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; public sealed class Http11ServerDecoderSecuritySpec { - private static Http11ServerDecoder MakeDecoder(SharedHttpOptions? shared = null) + private static Http11ServerDecoderOptions DefaultDecoderOptions() => new() { - var options = shared != null - ? new Http11ServerDecoderOptions { Shared = shared } - : Http11ServerDecoderOptions.Default; - return new Http11ServerDecoder(options); + MaxPipelinedRequests = 10, + MaxChunkExtensionLength = 4 * 1024, + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 4 * 1024 * 1024, + MaxStreamedBodySize = null, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + HeaderLineMaxLength = 8 * 1024, + RequestLineMaxLength = 8 * 1024, + MaxRequestTargetLength = 8 * 1024, + AllowObsFold = false + }; + + private static Http11ServerDecoder MakeDecoder(Http11ServerDecoderOptions? options = null) + { + return new Http11ServerDecoder(options ?? DefaultDecoderOptions()); } [Fact(Timeout = 5000)] @@ -114,9 +126,13 @@ public void Feed_should_parse_chunked_request_body() "0\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(request); - var outcome = decoder.Feed(bytes, out _); + var outcome = decoder.Feed(bytes, out var consumed); - Assert.Equal(DecodeOutcome.Complete, outcome); + Assert.Equal(DecodeOutcome.HeadersReady, outcome); + + var bodyOutcome = decoder.Feed(bytes.AsMemory(consumed), out _); + + Assert.Equal(DecodeOutcome.Complete, bodyOutcome); } [Fact(Timeout = 5000)] @@ -132,9 +148,13 @@ public void Feed_should_accept_chunk_size_with_leading_zeros() "0\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(request); - var outcome = decoder.Feed(bytes, out _); + var outcome = decoder.Feed(bytes, out var consumed); - Assert.Equal(DecodeOutcome.Complete, outcome); + Assert.Equal(DecodeOutcome.HeadersReady, outcome); + + var bodyOutcome = decoder.Feed(bytes.AsMemory(consumed), out _); + + Assert.Equal(DecodeOutcome.Complete, bodyOutcome); } [Fact(Timeout = 5000)] @@ -217,8 +237,8 @@ public void Reset_should_allow_decoding_next_request() [Trait("RFC", "RFC9112-5.2")] public void Feed_should_reject_obs_fold_when_not_allowed() { - var shared = new SharedHttpOptions { AllowObsFold = false }; - var decoder = MakeDecoder(shared); + var options = DefaultDecoderOptions() with { AllowObsFold = false }; + var decoder = MakeDecoder(options); const string request = "GET / HTTP/1.1\r\n" + "Host: example.com\r\n" + "X-Custom: value\r\n" + diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSpec.cs index fc08c62b3..f8fb1e99c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSpec.cs @@ -7,7 +7,22 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; public sealed class Http11ServerDecoderSpec { - private readonly Http11ServerDecoder _decoder = new(Http11ServerDecoderOptions.Default); + private static Http11ServerDecoderOptions DefaultDecoderOptions() => new() + { + MaxPipelinedRequests = 10, + MaxChunkExtensionLength = 4 * 1024, + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 4 * 1024 * 1024, + MaxStreamedBodySize = null, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + HeaderLineMaxLength = 8 * 1024, + RequestLineMaxLength = 8 * 1024, + MaxRequestTargetLength = 8 * 1024, + AllowObsFold = false + }; + + private readonly Http11ServerDecoder _decoder = new(DefaultDecoderOptions()); [Fact(Timeout = 5000)] public void Feed_should_decode_simple_request() @@ -78,7 +93,7 @@ public void Reset_should_clear_state() public void Feed_should_handle_bare_cr_in_request_line() { var raw = "GET /path\rHTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ServerDecoder(Http11ServerDecoderOptions.Default); + var decoder = new Http11ServerDecoder(DefaultDecoderOptions()); var outcome = decoder.Feed(raw, out _); @@ -90,7 +105,7 @@ public void Feed_should_handle_bare_cr_in_request_line() public void Feed_should_ignore_leading_crlf_before_request_line() { var raw = "\r\nGET /path HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ServerDecoder(Http11ServerDecoderOptions.Default); + var decoder = new Http11ServerDecoder(DefaultDecoderOptions()); var outcome = decoder.Feed(raw, out _); @@ -107,7 +122,7 @@ public void Feed_should_ignore_leading_crlf_before_request_line() public void Feed_should_reject_whitespace_before_first_header() { var raw = "GET / HTTP/1.1\r\n \r\nHost: x\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ServerDecoder(Http11ServerDecoderOptions.Default); + var decoder = new Http11ServerDecoder(DefaultDecoderOptions()); _ = Assert.Throws(() => decoder.Feed(raw, out _)); } @@ -117,7 +132,7 @@ public void Feed_should_reject_whitespace_before_first_header() public void Feed_should_accept_absolute_form_request_target() { var raw = "GET http://example.com/path HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); - var decoder = new Http11ServerDecoder(Http11ServerDecoderOptions.Default); + var decoder = new Http11ServerDecoder(DefaultDecoderOptions()); var outcome = decoder.Feed(raw, out _); @@ -130,8 +145,8 @@ public void Feed_should_accept_absolute_form_request_target() [Fact(Timeout = 5000)] public void GetRequestFeature_should_parse_method_and_path() { - var decoder = new Http11ServerDecoder(Http11ServerDecoderOptions.Default); - var data = "POST /api/items?page=2 HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"u8; + var decoder = new Http11ServerDecoder(DefaultDecoderOptions()); + var data = "POST /api/items?page=2 HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"u8.ToArray(); var outcome = decoder.Feed(data, out _); Assert.Equal(DecodeOutcome.Complete, outcome); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderHardeningSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderHardeningSpec.cs index e6b4e4480..29a113547 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderHardeningSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderHardeningSpec.cs @@ -8,8 +8,16 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; public sealed class Http11ServerEncoderHardeningSpec { + private static Http11ServerEncoderOptions DefaultEncoderOptions() => new() + { + KeepAliveTimeout = TimeSpan.FromSeconds(120), + RequestHeadersTimeout = TimeSpan.FromSeconds(30), + WriteDateHeader = true, + MaxHeaderBytes = 32 * 1024, + }; + private static Http11ServerEncoder MakeEncoder(bool withDate = false) => - new(Http11ServerEncoderOptions.Default with { WriteDateHeader = withDate }); + new(DefaultEncoderOptions() with { WriteDateHeader = withDate }); [Theory(Timeout = 5000)] [InlineData("Connection")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderSpec.cs index fa896924e..395262537 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerEncoderSpec.cs @@ -8,7 +8,15 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; public sealed class Http11ServerEncoderSpec { - private readonly Http11ServerEncoder _encoder = new(Http11ServerEncoderOptions.Default); + private static Http11ServerEncoderOptions DefaultEncoderOptions() => new() + { + KeepAliveTimeout = TimeSpan.FromSeconds(120), + RequestHeadersTimeout = TimeSpan.FromSeconds(30), + WriteDateHeader = true, + MaxHeaderBytes = 32 * 1024, + }; + + private readonly Http11ServerEncoder _encoder = new(DefaultEncoderOptions()); [Fact(Timeout = 5000)] public void Encode_should_write_status_line() diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningLimitSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningLimitSpec.cs index 823014732..1086c9b55 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningLimitSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningLimitSpec.cs @@ -33,11 +33,11 @@ public void ServerStateMachine_should_accept_requests_up_to_limit() MaxPipelinedRequests = 3 } }; - var sm = new Http11ServerStateMachine(options, ops); + var sm = new Http11ServerStateMachine(options.ToHttp1Options(), options.ToHttp2Options(), ops); var request = BuildPipelinedRequests(3); var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Equal(3, ops.Requests.Count); Assert.False(sm.ShouldComplete); @@ -55,11 +55,11 @@ public void ServerStateMachine_should_enforce_pipelining_limit() MaxPipelinedRequests = 2 } }; - var sm = new Http11ServerStateMachine(options, ops); + var sm = new Http11ServerStateMachine(options.ToHttp1Options(), options.ToHttp2Options(), ops); var request = BuildPipelinedRequests(4); // Try to send 4 requests var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Should only accept 2 requests (the limit) Assert.Equal(2, ops.Requests.Count); @@ -79,11 +79,11 @@ public void ServerStateMachine_should_close_after_limit_reached_response() MaxPipelinedRequests = 1 } }; - var sm = new Http11ServerStateMachine(options, ops); + var sm = new Http11ServerStateMachine(options.ToHttp1Options(), options.ToHttp2Options(), ops); var request = BuildPipelinedRequests(2); // Try to send 2 requests with limit 1 var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Requests); Assert.True(sm.ShouldComplete); @@ -101,11 +101,11 @@ public void ServerStateMachine_should_close_after_limit_reached_response() public void ServerStateMachine_default_limit_should_be_16() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var request = BuildPipelinedRequests(16); var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Equal(16, ops.Requests.Count); Assert.False(sm.ShouldComplete); @@ -116,11 +116,11 @@ public void ServerStateMachine_default_limit_should_be_16() public void ServerStateMachine_should_reject_17th_request_with_default_limit() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var request = BuildPipelinedRequests(17); var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Equal(16, ops.Requests.Count); Assert.True(sm.ShouldComplete); @@ -138,11 +138,11 @@ public void ServerStateMachine_should_accept_high_limit() MaxPipelinedRequests = 100 } }; - var sm = new Http11ServerStateMachine(options, ops); + var sm = new Http11ServerStateMachine(options.ToHttp1Options(), options.ToHttp2Options(), ops); var request = BuildPipelinedRequests(100); var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Equal(100, ops.Requests.Count); Assert.False(sm.ShouldComplete); @@ -161,7 +161,7 @@ public void ServerStateMachine_should_throw_on_invalid_limit() MaxPipelinedRequests = 0 } }; - Assert.Throws(() => new Http11ServerStateMachine(invalidOpts1, ops)); + Assert.Throws(() => new Http11ServerStateMachine(invalidOpts1.ToHttp1Options(), invalidOpts1.ToHttp2Options(), ops)); var invalidOpts2 = new TurboServerOptions { @@ -170,7 +170,7 @@ public void ServerStateMachine_should_throw_on_invalid_limit() MaxPipelinedRequests = -1 } }; - Assert.Throws(() => new Http11ServerStateMachine(invalidOpts2, ops)); + Assert.Throws(() => new Http11ServerStateMachine(invalidOpts2.ToHttp1Options(), invalidOpts2.ToHttp2Options(), ops)); } [Fact(Timeout = 5000)] @@ -185,16 +185,16 @@ public void ServerStateMachine_limit_applies_per_buffer() MaxPipelinedRequests = 2 } }; - var sm = new Http11ServerStateMachine(options, ops); + var sm = new Http11ServerStateMachine(options.ToHttp1Options(), options.ToHttp2Options(), ops); // First buffer with 2 requests var buffer1 = MakeBuffer(BuildPipelinedRequests(2)); - sm.DecodeClientData(new TransportData(buffer1)); + sm.DecodeClientData(TransportData.Rent(buffer1)); Assert.Equal(2, ops.Requests.Count); // Second buffer with 2 more requests - should also be limited (total would be 4) var buffer2 = MakeBuffer(BuildPipelinedRequests(2)); - sm.DecodeClientData(new TransportData(buffer2)); + sm.DecodeClientData(TransportData.Rent(buffer2)); // After hitting limit in first buffer and closing, second buffer should not add more // (behavior depends on whether ShouldCloseAfterResponse prevents further decoding) diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningSpec.cs index c30bb32bc..0f8dbe010 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerPipeliningSpec.cs @@ -26,7 +26,7 @@ private static IFeatureCollection CreateResponseContext() public void ServerStateMachine_should_decode_two_pipelined_requests_from_single_buffer() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var request = string.Concat( "GET / HTTP/1.1\r\n", "Host: example.com\r\n", @@ -38,7 +38,7 @@ public void ServerStateMachine_should_decode_two_pipelined_requests_from_single_ "\r\n"); var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Equal(2, ops.Requests.Count); Assert.Equal("/", ops.Requests[0].Get()?.Path); @@ -50,7 +50,7 @@ public void ServerStateMachine_should_decode_two_pipelined_requests_from_single_ public void ServerStateMachine_should_process_responses_fifo_for_pipelined_requests() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var request = string.Concat( "GET / HTTP/1.1\r\n", "Host: example.com\r\n", @@ -62,7 +62,7 @@ public void ServerStateMachine_should_process_responses_fifo_for_pipelined_reque "\r\n"); var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var context1 = CreateResponseContext(); sm.OnResponse(context1); @@ -78,7 +78,7 @@ public void ServerStateMachine_should_process_responses_fifo_for_pipelined_reque public void ServerStateMachine_should_throw_when_responding_without_pending_request() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var context = CreateResponseContext(); @@ -90,7 +90,7 @@ public void ServerStateMachine_should_throw_when_responding_without_pending_requ public void ServerStateMachine_should_handle_three_pipelined_requests() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var request = string.Concat( "GET /page1 HTTP/1.1\r\n", "Host: example.com\r\n", @@ -106,7 +106,7 @@ public void ServerStateMachine_should_handle_three_pipelined_requests() "\r\n"); var buffer = MakeBuffer(request); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Equal(3, ops.Requests.Count); Assert.Equal("/page1", ops.Requests[0].Get()?.Path); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineConnectionSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineConnectionSpec.cs index 8ea1a8d50..7ad88f88e 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineConnectionSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineConnectionSpec.cs @@ -1,12 +1,11 @@ -using System.Buffers; using System.Text; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http11.Server; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Tests.Shared; +using static TurboHTTP.Protocol.Syntax.Http11.Server.Http11ServerStateMachine; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; @@ -37,12 +36,12 @@ private static TransportBuffer MakeBuffer(string raw) public void ShouldComplete_should_be_true_when_connection_close_on_request() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); Assert.Single(ops.Requests); @@ -53,12 +52,12 @@ public void ShouldComplete_should_be_true_when_connection_close_on_request() public void ShouldComplete_should_be_true_for_http10_request_on_h11_connection() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); Assert.Single(ops.Requests); @@ -69,12 +68,12 @@ public void ShouldComplete_should_be_true_for_http10_request_on_h11_connection() public void OnResponse_should_include_connection_close_when_ShouldComplete() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); var context = CreateResponseContext(); @@ -91,27 +90,27 @@ public void OnResponse_should_include_connection_close_when_ShouldComplete() public void DecodeClientData_should_set_ShouldComplete_on_decode_error() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string invalidRequest = "INVALID REQUEST DATA\r\n\r\n"; var buffer = MakeBuffer(invalidRequest); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-4")] - public void OnBodyMessage_OutboundBodyFailed_should_clear_pending_flag() + public void OnBodyMessage_ResponseBodyReadFailed_should_clear_pending_flag() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.CanAcceptResponse); var context = CreateResponseContext(); @@ -122,8 +121,7 @@ public void OnBodyMessage_OutboundBodyFailed_should_clear_pending_flag() Assert.False(sm.CanAcceptResponse); // Send body failed - var failed = new OutboundBodyFailed(new Exception("Test failure")); - sm.OnBodyMessage(failed); + sm.OnBodyMessage(new ResponseBodyReadFailed(new Exception("Test failure"))); // After body failed, CanAcceptResponse is false because _pendingResponseCount == 0 (response already sent) // not because body is pending @@ -135,38 +133,26 @@ public void OnBodyMessage_OutboundBodyFailed_should_clear_pending_flag() public void OnBodyMessage_multi_chunk_should_emit_all_chunks() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var context = CreateResponseContext(); sm.OnResponse(context); var headerCount = ops.Outbound.Count; - // Send first chunk - var owner1 = MemoryPool.Shared.Rent(5); - "hello"u8.CopyTo(owner1.Memory.Span); - sm.OnBodyMessage(new OutboundBodyChunk(owner1, 5)); - - // Send second chunk - var owner2 = MemoryPool.Shared.Rent(6); - " world"u8.CopyTo(owner2.Memory.Span); - sm.OnBodyMessage(new OutboundBodyChunk(owner2, 6)); - - // Complete body - sm.OnBodyMessage(new OutboundBodyComplete()); + // Send two read completions followed by EOF + sm.OnBodyMessage(new ResponseBodyReadComplete(5)); + sm.OnBodyMessage(new ResponseBodyReadComplete(6)); + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); + // 2 data chunks + 1 chunked terminator from CompleteAsync var bodyChunks = ops.Outbound.Skip(headerCount).OfType().ToList(); - Assert.Equal(2, bodyChunks.Count); - - var chunk1Text = Encoding.UTF8.GetString(bodyChunks[0].Buffer.Span); - var chunk2Text = Encoding.UTF8.GetString(bodyChunks[1].Buffer.Span); - Assert.Equal("hello", chunk1Text); - Assert.Equal(" world", chunk2Text); + Assert.Equal(3, bodyChunks.Count); } [Fact(Timeout = 5000)] @@ -174,12 +160,12 @@ public void OnBodyMessage_multi_chunk_should_emit_all_chunks() public void Cleanup_should_be_idempotent() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var context = CreateResponseContext(); sm.OnResponse(context); @@ -197,7 +183,7 @@ public void Cleanup_should_be_idempotent() public void OnResponse_should_throw_when_no_pending_requests() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var context = CreateResponseContext(); @@ -210,11 +196,11 @@ public void OnResponse_should_throw_when_no_pending_requests() public void OnResponse_should_set_chunked_transfer_encoding_when_no_content_length() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var context = CreateResponseContext(); context.Get()?.StatusCode = 200; @@ -235,11 +221,11 @@ public void OnResponse_should_set_chunked_transfer_encoding_when_no_content_leng public void OnResponse_should_not_set_chunked_when_content_length_present() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); const string requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var context = CreateResponseContext(); context.Get()?.StatusCode = 200; diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineTimerSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineTimerSpec.cs index 7f0494b7a..4565ddb46 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineTimerSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerStateMachineTimerSpec.cs @@ -1,11 +1,11 @@ using System.Text; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http11.Server; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Tests.Shared; +using static TurboHTTP.Protocol.Syntax.Http11.Server.Http11ServerStateMachine; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; @@ -36,7 +36,7 @@ private static TransportBuffer MakeBuffer(string raw) public void OnTimerFired_request_headers_should_set_ShouldComplete() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); sm.OnTimerFired("request-headers"); @@ -48,7 +48,7 @@ public void OnTimerFired_request_headers_should_set_ShouldComplete() public void OnTimerFired_keep_alive_should_set_ShouldComplete() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); sm.OnTimerFired("keep-alive"); @@ -60,14 +60,14 @@ public void OnTimerFired_keep_alive_should_set_ShouldComplete() public void DecodeClientData_should_schedule_request_headers_timer() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); // Feed partial request data (no final \r\n\r\n) to trigger NeedMore state // This keeps the decoder in incomplete state, allowing timer scheduling var partialRequest = "GET / HTTP/1.1\r\nHost: localhost\r\n"; var buffer = MakeBuffer(partialRequest); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Contains(ops.ScheduledTimers, t => t.Name == "request-headers"); } @@ -77,17 +77,17 @@ public void DecodeClientData_should_schedule_request_headers_timer() public void DecodeClientData_should_cancel_request_headers_timer_when_complete() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); // First, feed partial request to schedule timer var partialRequest = "GET / HTTP/1.1\r\nHost: localhost\r\n"; var buffer1 = MakeBuffer(partialRequest); - sm.DecodeClientData(new TransportData(buffer1)); + sm.DecodeClientData(TransportData.Rent(buffer1)); // Then feed completion to cancel timer var completion = "\r\n"; var buffer2 = MakeBuffer(completion); - sm.DecodeClientData(new TransportData(buffer2)); + sm.DecodeClientData(TransportData.Rent(buffer2)); Assert.Contains(ops.CancelledTimers, t => t == "request-headers"); } @@ -98,12 +98,12 @@ public void OnResponse_should_schedule_keep_alive_timer_after_204_body_completes { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); // Decode a complete request first var requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Verify we have a pending request Assert.Single(ops.Requests); @@ -118,7 +118,7 @@ public void OnResponse_should_schedule_keep_alive_timer_after_204_body_completes var timersBeforeBodyComplete = ops.ScheduledTimers.ToList(); // Complete the body (even though it's empty) - sm.OnBodyMessage(new OutboundBodyComplete()); + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); // Check that keep-alive timer was scheduled after body completion var newTimers = ops.ScheduledTimers.Skip(timersBeforeBodyComplete.Count).ToList(); @@ -130,41 +130,87 @@ public void OnResponse_should_schedule_keep_alive_timer_after_204_body_completes public void OnBodyMessage_complete_should_schedule_keep_alive_timer() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); // Decode a request var requestData = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; var buffer = MakeBuffer(requestData); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Send response with body var context = CreateResponseContext(); sm.OnResponse(context); - // Send body chunks and completion - var bodyBytes = "Hello"u8.ToArray(); - var owner = System.Buffers.MemoryPool.Shared.Rent(bodyBytes.Length); - bodyBytes.CopyTo(owner.Memory.Span); - sm.OnBodyMessage(new OutboundBodyChunk(owner, bodyBytes.Length)); + // Send body chunk and completion + sm.OnBodyMessage(new ResponseBodyReadComplete(5)); // Complete the body — this should schedule keep-alive timer - sm.OnBodyMessage(new OutboundBodyComplete()); + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); Assert.Contains(ops.ScheduledTimers, t => t.Name == "keep-alive"); } + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void DecodeClientData_should_schedule_body_read_timer_while_body_streaming() + { + var opts = new TurboServerOptions + { + Http1 = + { + BodyReadTimeout = TimeSpan.FromSeconds(5) + } + }; + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(opts.ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); + + var req = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n"; + sm.DecodeClientData(TransportData.Rent(MakeBuffer(req))); + + Assert.Contains(ops.ScheduledTimers, t => t.Name == "body-read" && t.Delay == TimeSpan.FromSeconds(5)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void OnTimerFired_body_read_should_set_ShouldComplete() + { + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); + + sm.OnTimerFired("body-read"); + + Assert.True(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9112-6.1")] + public void DecodeClientData_should_cancel_body_read_timer_when_body_completes() + { + var ops = new FakeServerOps(); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); + + var head = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n"; + sm.DecodeClientData(TransportData.Rent(MakeBuffer(head))); + Assert.Contains(ops.ScheduledTimers, t => t.Name == "body-read"); + + var body = "5\r\nhello\r\n0\r\n\r\n"; + sm.DecodeClientData(TransportData.Rent(MakeBuffer(body))); + + Assert.Contains(ops.CancelledTimers, t => t == "body-read"); + } + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-6.5")] public void Cleanup_should_cancel_all_timers() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); // Decode a partial request to activate request-headers timer var partialRequest = "GET / HTTP/1.1\r\nHost: localhost\r\n"; var buffer = MakeBuffer(partialRequest); - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Contains(ops.ScheduledTimers, t => t.Name == "request-headers"); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11UpgradeH2cSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11UpgradeH2cSpec.cs index f3b473881..8f0c4f770 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11UpgradeH2cSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11UpgradeH2cSpec.cs @@ -54,7 +54,7 @@ private static TransportData MakeData(string raw) var buffer = TransportBuffer.Rent(data.Length); data.CopyTo(buffer.FullMemory.Span); buffer.Length = data.Length; - return new TransportData(buffer); + return TransportData.Rent(buffer); } [Fact(Timeout = 5000)] @@ -62,7 +62,7 @@ private static TransportData MakeData(string raw) public void DecodeClientData_should_trigger_switch_when_upgrade_h2c_with_switchable_ops() { var ops = new SwitchCapableOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); sm.DecodeClientData(MakeData( "GET / HTTP/1.1\r\n" + @@ -86,7 +86,7 @@ public void DecodeClientData_should_trigger_switch_when_upgrade_h2c_with_switcha public void DecodeClientData_should_ignore_upgrade_when_ops_not_switchable() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); sm.DecodeClientData(MakeData( "GET / HTTP/1.1\r\n" + @@ -106,7 +106,7 @@ public void DecodeClientData_should_ignore_upgrade_when_ops_not_switchable() public void DecodeClientData_should_ignore_upgrade_without_http2_settings() { var ops = new SwitchCapableOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); sm.DecodeClientData(MakeData( "GET / HTTP/1.1\r\n" + diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/ServerStateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/ServerStateMachineSpec.cs index 4736e4c80..2b10f98d6 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/ServerStateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/ServerStateMachineSpec.cs @@ -2,11 +2,11 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; using Servus.Akka.Transport; -using TurboHTTP.Protocol; using TurboHTTP.Protocol.Syntax.Http11.Server; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Tests.Shared; +using static TurboHTTP.Protocol.Syntax.Http11.Server.Http11ServerStateMachine; namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server; @@ -17,7 +17,7 @@ public sealed class ServerStateMachineSpec public void DecodeClientData_should_emit_request_when_complete_get() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -29,7 +29,7 @@ public void DecodeClientData_should_emit_request_when_complete_get() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Requests); var ctx = ops.Requests[0]; @@ -42,7 +42,7 @@ public void DecodeClientData_should_emit_request_when_complete_get() public void OnResponse_should_emit_response_headers() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -54,7 +54,7 @@ public void OnResponse_should_emit_response_headers() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var responseBody = "Hello"u8.ToArray(); var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) @@ -80,7 +80,7 @@ public void OnResponse_should_emit_response_headers() public void CanAcceptResponse_should_be_false_when_no_pending_requests() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); Assert.False(sm.CanAcceptResponse); } @@ -90,7 +90,7 @@ public void CanAcceptResponse_should_be_false_when_no_pending_requests() public void CanAcceptResponse_should_be_true_after_request_decoded() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -102,7 +102,7 @@ public void CanAcceptResponse_should_be_true_after_request_decoded() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.CanAcceptResponse); } @@ -112,7 +112,7 @@ public void CanAcceptResponse_should_be_true_after_request_decoded() public void ShouldCloseAfterResponse_should_be_true_when_connection_close_header() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -125,7 +125,7 @@ public void ShouldCloseAfterResponse_should_be_true_when_connection_close_header requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); } @@ -135,7 +135,7 @@ public void ShouldCloseAfterResponse_should_be_true_when_connection_close_header public void ShouldCloseAfterResponse_should_be_true_when_http_10_request() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.0\r\n" + @@ -147,7 +147,7 @@ public void ShouldCloseAfterResponse_should_be_true_when_http_10_request() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.ShouldComplete); } @@ -157,7 +157,7 @@ public void ShouldCloseAfterResponse_should_be_true_when_http_10_request() public void OnResponse_should_set_connection_close_header_when_flag_set() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -170,7 +170,7 @@ public void OnResponse_should_set_connection_close_header_when_flag_set() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { @@ -190,7 +190,7 @@ public void OnResponse_should_set_connection_close_header_when_flag_set() public void OnResponse_should_not_include_body_in_transport_data() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -202,7 +202,7 @@ public void OnResponse_should_not_include_body_in_transport_data() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { @@ -223,7 +223,7 @@ public void OnResponse_should_not_include_body_in_transport_data() public void OnBodyMessage_should_emit_body_chunk_as_transport_data() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -235,7 +235,7 @@ public void OnBodyMessage_should_emit_body_chunk_as_transport_data() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { @@ -245,16 +245,11 @@ public void OnBodyMessage_should_emit_body_chunk_as_transport_data() sm.OnResponse(MakeResponseContext(response)); var countAfterHeaders = ops.Outbound.Count; - var bodyBytes = "hello world"u8.ToArray(); - var owner = System.Buffers.MemoryPool.Shared.Rent(bodyBytes.Length); - bodyBytes.CopyTo(owner.Memory.Span); - sm.OnBodyMessage(new OutboundBodyChunk(owner, bodyBytes.Length)); - sm.OnBodyMessage(new OutboundBodyComplete()); + sm.OnBodyMessage(new ResponseBodyReadComplete(11)); + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); var bodyItems = ops.Outbound.Skip(countAfterHeaders).OfType().ToList(); Assert.NotEmpty(bodyItems); - var bodyText = Encoding.UTF8.GetString(bodyItems[0].Buffer.Span); - Assert.Contains("hello world", bodyText); } [Fact(Timeout = 5000)] @@ -262,7 +257,7 @@ public void OnBodyMessage_should_emit_body_chunk_as_transport_data() public void CanAcceptResponse_should_be_false_when_outbound_body_pending() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -274,7 +269,7 @@ public void CanAcceptResponse_should_be_false_when_outbound_body_pending() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { @@ -285,7 +280,7 @@ public void CanAcceptResponse_should_be_false_when_outbound_body_pending() Assert.False(sm.CanAcceptResponse); - sm.OnBodyMessage(new OutboundBodyComplete()); + sm.OnBodyMessage(new ResponseBodyReadComplete(0)); Assert.False(sm.CanAcceptResponse); } @@ -295,7 +290,7 @@ public void CanAcceptResponse_should_be_false_when_outbound_body_pending() public void DecodeClientData_should_signal_error_for_oversized_uri() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var longUri = "/" + new string('a', 16_000); var requestData = Encoding.ASCII.GetBytes( @@ -308,7 +303,7 @@ public void DecodeClientData_should_signal_error_for_oversized_uri() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(ops.Requests.Count is 0 or 1); } @@ -318,7 +313,7 @@ public void DecodeClientData_should_signal_error_for_oversized_uri() public void OnResponse_should_not_include_transfer_encoding_for_204() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "GET / HTTP/1.1\r\n" + @@ -330,7 +325,7 @@ public void OnResponse_should_not_include_transfer_encoding_for_204() requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); var response = new HttpResponseMessage(System.Net.HttpStatusCode.NoContent); sm.OnResponse(MakeResponseContext(response)); @@ -345,10 +340,10 @@ public void OnResponse_should_not_include_transfer_encoding_for_204() [Fact(Timeout = 5000)] [Trait("RFC", "RFC9112-6.1")] - public void DecodeClientData_should_pass_unknown_transfer_encoding_to_application() + public void DecodeClientData_should_reject_unknown_transfer_encoding() { var ops = new FakeServerOps(); - var sm = new Http11ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http11ServerStateMachine(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops); var requestData = Encoding.ASCII.GetBytes( "POST / HTTP/1.1\r\n" + @@ -360,13 +355,13 @@ public void DecodeClientData_should_pass_unknown_transfer_encoding_to_applicatio requestData.CopyTo(buffer.FullMemory.Span); buffer.Length = requestData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); - // §6.1 SHOULD respond 501 — but the SM passes the request to the application layer - // which is responsible for inspecting TE and returning 501. The SM correctly decodes - // the request structure and preserves the TE header for application inspection. - Assert.Single(ops.Requests); - Assert.Equal("POST", ops.Requests[0].Get()?.Method); + // RFC 9112 §6.1: a request whose final transfer coding is not chunked has no reliable body + // length and MUST NOT be forwarded — doing so enables request smuggling. The SM rejects it + // and closes the connection instead of passing it to the application. + Assert.Empty(ops.Requests); + Assert.True(sm.ShouldComplete); } private static IFeatureCollection MakeResponseContext(HttpResponseMessage response) diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageReconnectSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageReconnectSpec.cs index 6ffdabc97..ee1bf55e7 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageReconnectSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageReconnectSpec.cs @@ -92,7 +92,7 @@ public async Task Http11ConnectionStage_should_reconnect_and_replay_request_on_c tdRetry.Buffer.Dispose(); // Now respond normally - serverSub.SendNext(new TransportData(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"))); + serverSub.SendNext(TransportData.Rent(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"))); var response = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageSpec.cs index 5225c54a2..08f6de4d5 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Stages/Http11ConnectionStageSpec.cs @@ -123,7 +123,7 @@ public async Task Http11ConnectionStage_should_decode_response_and_correlate_wit await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); - serverSubscription.SendNext(new TransportData(MakeResponseBuffer( + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer( "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"))); var response = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); @@ -178,14 +178,14 @@ public async Task Http11ConnectionStage_should_support_pipelining_multiple_reque await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); // Send first response - serverSubscription.SendNext(new TransportData(MakeResponseBuffer( + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer( "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nfirst"))); var resp1 = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); Assert.Equal("/first", resp1.RequestMessage!.RequestUri!.AbsolutePath); // Send second response - serverSubscription.SendNext(new TransportData(MakeResponseBuffer( + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer( "HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nsecond"))); var resp2 = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); @@ -242,11 +242,11 @@ public async Task Http11ConnectionStage_should_pipeline_requests_up_to_max_depth // All 3 requests should have been accepted and encoded. // Now send the 3 responses serverSubscription.SendNext( - new TransportData(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres1"))); + TransportData.Rent(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres1"))); serverSubscription.SendNext( - new TransportData(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres2"))); + TransportData.Rent(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres2"))); serverSubscription.SendNext( - new TransportData(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres3"))); + TransportData.Rent(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres3"))); // Should get 3 responses var resp1 = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); @@ -306,7 +306,7 @@ public async Task Http11ConnectionStage_should_reduce_pipeline_depth_when_connec // Send response with Connection: close header var responseWithClose = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\nres1"; - serverSubscription.SendNext(new TransportData(MakeResponseBuffer(responseWithClose))); + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer(responseWithClose))); // Get response var response = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); @@ -321,7 +321,7 @@ public async Task Http11ConnectionStage_should_reduce_pipeline_depth_when_connec // Send response for req2 serverSubscription.SendNext( - new TransportData(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres2"))); + TransportData.Rent(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nres2"))); var response2 = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response2.StatusCode); @@ -369,7 +369,7 @@ public async Task Http11ConnectionStage_should_emit_connection_reuse_keep_alive_ await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); - serverSubscription.SendNext(new TransportData(MakeResponseBuffer( + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer( "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"))); await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); @@ -418,12 +418,12 @@ public async Task Http11ConnectionStage_should_handle_100_continue_response() await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); // Send 100 Continue (informational, not final) - serverSubscription.SendNext(new TransportData(MakeResponseBuffer("HTTP/1.1 100 Continue\r\n\r\n"))); + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer("HTTP/1.1 100 Continue\r\n\r\n"))); // Continue response should be processed internally (not emitted downstream typically) // Send final response after 100 Continue serverSubscription.SendNext( - new TransportData(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\nSuccess"))); + TransportData.Rent(MakeResponseBuffer("HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\nSuccess"))); var response = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -471,7 +471,7 @@ public async Task Http11ConnectionStage_should_handle_connection_close_header() await networkSub.ExpectNextAsync(TestContext.Current.CancellationToken); // Server sends Connection: close header - serverSubscription.SendNext(new TransportData(MakeResponseBuffer( + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer( "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nOK"))); var response = await responseSub.ExpectNextAsync(TestContext.Current.CancellationToken); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/CookieHeaderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/CookieHeaderSpec.cs index 501d03866..3afa12178 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/CookieHeaderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/CookieHeaderSpec.cs @@ -41,7 +41,7 @@ public void ResponseDecoder_should_receive_multiple_cookie_headers_from_hpack() var state = new StreamState(); state.AppendHeader(block.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(1, endStream: true, state); Assert.NotNull(response); @@ -64,7 +64,7 @@ public void ResponseDecoder_should_accept_single_cookie_header_unchanged() var state = new StreamState(); state.AppendHeader(block.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(1, endStream: true, state); Assert.NotNull(response); @@ -107,7 +107,7 @@ public void ResponseDecoder_should_handle_empty_cookie_value() var state = new StreamState(); state.AppendHeader(block.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(1, endStream: true, state); Assert.NotNull(response); @@ -131,7 +131,7 @@ public void ResponseDecoder_should_preserve_all_cookie_headers() var state = new StreamState(); state.AppendHeader(block.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(1, endStream: true, state); Assert.NotNull(response); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2InterimResponseSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2InterimResponseSpec.cs new file mode 100644 index 000000000..fb9ef415d --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2InterimResponseSpec.cs @@ -0,0 +1,110 @@ +using System.Net; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Client; +using TurboHTTP.Protocol.Syntax.Http2.Hpack; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Client.Decoder; + +public sealed class Http2InterimResponseSpec +{ + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public void DecodeHeaders_should_not_set_HasResponse_for_100_continue() + { + var encoder = new HpackEncoder(useHuffman: false); + var encoded = encoder.Encode([(":status", "100")]); + var state = new StreamState(); + state.AppendHeader(encoded.Span); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); + + decoder.DecodeHeaders(streamId: 1, endStream: false, state); + + Assert.False(state.HasResponse); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public void DecodeHeaders_should_not_set_HasResponse_for_103_early_hints() + { + var encoder = new HpackEncoder(useHuffman: false); + var encoded = encoder.Encode([(":status", "103")]); + var state = new StreamState(); + state.AppendHeader(encoded.Span); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); + + decoder.DecodeHeaders(streamId: 1, endStream: false, state); + + Assert.False(state.HasResponse); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public void DecodeHeaders_should_return_interim_response_object() + { + var encoder = new HpackEncoder(useHuffman: false); + var encoded = encoder.Encode([(":status", "100")]); + var state = new StreamState(); + state.AppendHeader(encoded.Span); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); + + var response = decoder.DecodeHeaders(streamId: 1, endStream: false, state); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Continue, response.StatusCode); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public void DecodeHeaders_should_set_HasResponse_for_200_ok() + { + var encoder = new HpackEncoder(useHuffman: false); + var encoded = encoder.Encode([(":status", "200")]); + var state = new StreamState(); + state.AppendHeader(encoded.Span); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); + + decoder.DecodeHeaders(streamId: 1, endStream: true, state); + + Assert.True(state.HasResponse); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public void DecodeHeaders_should_allow_final_response_after_100_continue() + { + var encoder = new HpackEncoder(useHuffman: false); + var state = new StreamState(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); + + var interimEncoded = encoder.Encode([(":status", "100")]); + state.AppendHeader(interimEncoded.Span); + decoder.DecodeHeaders(streamId: 1, endStream: false, state); + + state.ClearHeaderBuffer(); + + var finalEncoded = encoder.Encode([(":status", "200"), ("content-type", "text/plain")]); + state.AppendHeader(finalEncoded.Span); + var finalResponse = decoder.DecodeHeaders(streamId: 1, endStream: true, state); + + Assert.NotNull(finalResponse); + Assert.Equal(HttpStatusCode.OK, finalResponse.StatusCode); + Assert.True(state.HasResponse); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public void DecodeHeadersForStreaming_should_not_set_HasResponse_for_1xx() + { + var encoder = new HpackEncoder(useHuffman: false); + var encoded = encoder.Encode([(":status", "103")]); + var state = new StreamState(); + state.AppendHeader(encoded.Span); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); + + var response = decoder.DecodeHeadersForStreaming(streamId: 1, state); + + Assert.NotNull(response); + Assert.Equal((HttpStatusCode)103, response.StatusCode); + Assert.False(state.HasResponse); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2ResponseDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2ResponseDecoderSpec.cs index 99a18ad50..78fc75da6 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2ResponseDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2ResponseDecoderSpec.cs @@ -15,7 +15,7 @@ public void DecodeHeaders_should_decode_status_pseudo_header() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -31,7 +31,7 @@ public void DecodeHeaders_should_set_response_on_state() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -47,7 +47,7 @@ public void DecodeHeaders_should_handle_100_continue() var encoded = encoder.Encode([(":status", "100")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -63,7 +63,7 @@ public void DecodeHeaders_should_handle_201_created() var encoded = encoder.Encode([(":status", "201")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -79,7 +79,7 @@ public void DecodeHeaders_should_handle_204_no_content() var encoded = encoder.Encode([(":status", "204")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -95,7 +95,7 @@ public void DecodeHeaders_should_handle_304_not_modified() var encoded = encoder.Encode([(":status", "304")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -111,7 +111,7 @@ public void DecodeHeaders_should_handle_400_bad_request() var encoded = encoder.Encode([(":status", "400")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -127,7 +127,7 @@ public void DecodeHeaders_should_handle_401_unauthorized() var encoded = encoder.Encode([(":status", "401")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -143,7 +143,7 @@ public void DecodeHeaders_should_handle_403_forbidden() var encoded = encoder.Encode([(":status", "403")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -159,7 +159,7 @@ public void DecodeHeaders_should_handle_404_not_found() var encoded = encoder.Encode([(":status", "404")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -175,7 +175,7 @@ public void DecodeHeaders_should_handle_500_server_error() var encoded = encoder.Encode([(":status", "500")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -191,7 +191,7 @@ public void DecodeHeaders_should_handle_502_bad_gateway() var encoded = encoder.Encode([(":status", "502")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -207,7 +207,7 @@ public void DecodeHeaders_should_handle_503_service_unavailable() var encoded = encoder.Encode([(":status", "503")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -223,7 +223,7 @@ public void DecodeHeaders_should_ignore_pseudo_headers_other_than_status() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -240,7 +240,7 @@ public void DecodeHeaders_should_return_null_when_endStream_is_false() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: false, state); @@ -256,7 +256,7 @@ public void DecodeHeaders_should_throw_on_missing_status_pseudo_header() var encoded = encoder.Encode([]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); } @@ -269,7 +269,7 @@ public void DecodeHeaders_should_set_content_for_headers_only_response() var encoded = encoder.Encode([(":status", "204")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -285,7 +285,7 @@ public void DecodeHeaders_should_throw_when_single_header_exceeds_max_size() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(maxHeaderSize: 1); // Very small limit + var decoder = new Http2ClientDecoder(maxHeaderSize: 1, maxTotalHeaderSize: 64 * 1024); // Very small limit Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); } @@ -298,9 +298,10 @@ public void DecodeHeaders_should_throw_when_total_headers_exceed_max_size() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(maxTotalHeaderSize: 1); // Very small limit + var decoder = new Http2ClientDecoder(maxHeaderSize: 16 * 1024, maxTotalHeaderSize: 1); // Very small limit - Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); + // Total header-list size is enforced at the HPACK layer (RFC 9113 §6.5.2 / MAX_HEADER_LIST_SIZE). + Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); } [Fact(Timeout = 5000)] @@ -311,7 +312,7 @@ public void DecodeHeaders_should_include_stream_id_in_error_message() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(maxHeaderSize: 1); + var decoder = new Http2ClientDecoder(maxHeaderSize: 1, maxTotalHeaderSize: 64 * 1024); var ex = Assert.Throws(() => decoder.DecodeHeaders(streamId: 42, endStream: true, state)); @@ -322,7 +323,7 @@ public void DecodeHeaders_should_include_stream_id_in_error_message() [Trait("RFC", "RFC9113-6.5.2")] public void ResetHpack_should_create_new_decoder() { - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var initialDecoder = typeof(Http2ClientDecoder) .GetField("_hpack", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) ?.GetValue(decoder); @@ -343,7 +344,7 @@ public void DecodeHeaders_should_create_new_response_message() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response1 = decoder.DecodeHeaders(streamId: 1, endStream: true, state); var state2 = new StreamState(); @@ -363,7 +364,7 @@ public void DecodeHeaders_should_parse_numeric_status_code() var encoded = encoder.Encode([(":status", "418")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -379,7 +380,7 @@ public void DecodeHeaders_should_throw_on_invalid_status_code() var encoded = encoder.Encode([(":status", "invalid")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); } @@ -388,7 +389,7 @@ public void DecodeHeaders_should_throw_on_invalid_status_code() [Trait("RFC", "RFC9113-6.5.2")] public void DecodeHeaders_should_default_max_header_size_to_16kb() { - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); Assert.NotNull(decoder); } @@ -396,7 +397,7 @@ public void DecodeHeaders_should_default_max_header_size_to_16kb() [Trait("RFC", "RFC9113-6.5.2")] public void DecodeHeaders_should_default_max_total_header_size_to_64kb() { - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); Assert.NotNull(decoder); } @@ -413,7 +414,7 @@ public void DecodeHeaders_should_support_custom_max_header_limits() public void DecodeHeaders_with_empty_header_block() { var state = new StreamState(); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); } @@ -430,7 +431,7 @@ public void DecodeHeaders_should_handle_multiple_status_codes_across_streams() var encoded = encoder.Encode([(":status", status)]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -447,7 +448,7 @@ public void DecodeHeaders_should_create_response_with_empty_content_on_headers_o var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -463,7 +464,7 @@ public void DecodeHeaders_should_store_response_on_stream_state() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -480,7 +481,7 @@ public void DecodeHeaders_should_use_hpack_decoder_for_decompression() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: true, state); @@ -495,7 +496,7 @@ public void DecodeHeaders_should_handle_stream_id_for_error_scope() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(maxHeaderSize: 1); + var decoder = new Http2ClientDecoder(maxHeaderSize: 1, maxTotalHeaderSize: 64 * 1024); var ex = Assert.Throws(() => decoder.DecodeHeaders(streamId: 999, endStream: true, state)); @@ -510,7 +511,7 @@ public void DecodeHeaders_error_should_have_correct_error_code() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(maxHeaderSize: 1); + var decoder = new Http2ClientDecoder(maxHeaderSize: 1, maxTotalHeaderSize: 64 * 1024); Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); } @@ -523,7 +524,7 @@ public void DecodeHeaders_error_should_have_stream_scope() var encoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(encoded.Span); - var decoder = new Http2ClientDecoder(maxHeaderSize: 1); + var decoder = new Http2ClientDecoder(maxHeaderSize: 1, maxTotalHeaderSize: 64 * 1024); Assert.Throws(() => decoder.DecodeHeaders(streamId: 1, endStream: true, state)); } @@ -536,7 +537,7 @@ public void DecodeTrailers_should_populate_trailing_headers() var statusEncoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(statusEncoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: false, state); Assert.Null(response); @@ -560,7 +561,7 @@ public void DecodeTrailers_should_filter_prohibited_fields() var statusEncoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(statusEncoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: false, state); Assert.Null(response); @@ -582,7 +583,7 @@ public void DecodeTrailers_should_skip_pseudo_headers() var statusEncoded = encoder.Encode([(":status", "200")]); var state = new StreamState(); state.AppendHeader(statusEncoded.Span); - var decoder = new Http2ClientDecoder(); + var decoder = new Http2ClientDecoder(16 * 1024, 64 * 1024); var response = decoder.DecodeHeaders(streamId: 1, endStream: false, state); Assert.Null(response); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2StreamStateBodyTruncationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2StreamStateBodyTruncationSpec.cs new file mode 100644 index 000000000..230e7c043 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2StreamStateBodyTruncationSpec.cs @@ -0,0 +1,88 @@ +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http2; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Client.Decoder; + +/// +/// RFC 9113 §8.1.1: a stream that ends before (or after) the declared Content-Length is +/// malformed. When is set, END_STREAM with a +/// mismatched byte count must fault the body reader so the consumer observes an error +/// instead of a silently truncated body. +/// +[Trait("RFC", "RFC9113-8.1.1")] +public sealed class Http2StreamStateBodyTruncationSpec +{ + private static (StreamState State, Stream Body) CreateStreamingState(long? expectedLength) + { + var state = new StreamState(); + var reader = new QueuedBodyReader(capacity: 8); + reader.Reset(); + state.InitBodyReader(reader); + state.ExpectedBodyLength = expectedLength; + return (state, state.GetBodyStream()); + } + + private static async Task ReadToEndAsync(Stream stream, CancellationToken ct) + { + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, ct); + return ms.ToArray(); + } + + [Fact(Timeout = 5000)] + public async Task EndStream_before_content_length_should_fault_body_reader() + { + var (state, body) = CreateStreamingState(expectedLength: 10); + + state.FeedBody("12345"u8, endStream: true); + + var ex = await Assert.ThrowsAsync( + () => ReadToEndAsync(body, TestContext.Current.CancellationToken)); + Assert.Contains("Content-Length", ex.Message); + } + + [Fact(Timeout = 5000)] + public async Task EndStream_beyond_content_length_should_fault_body_reader() + { + var (state, body) = CreateStreamingState(expectedLength: 3); + + state.FeedBody("12345"u8, endStream: true); + + await Assert.ThrowsAsync( + () => ReadToEndAsync(body, TestContext.Current.CancellationToken)); + } + + [Fact(Timeout = 5000)] + public async Task EndStream_at_exact_content_length_should_complete_body() + { + var (state, body) = CreateStreamingState(expectedLength: 5); + + state.FeedBody("12345"u8, endStream: true); + + var bytes = await ReadToEndAsync(body, TestContext.Current.CancellationToken); + Assert.Equal("12345"u8.ToArray(), bytes); + } + + [Fact(Timeout = 5000)] + public async Task EndStream_without_expected_length_should_complete_body() + { + var (state, body) = CreateStreamingState(expectedLength: null); + + state.FeedBody("12345"u8, endStream: true); + + var bytes = await ReadToEndAsync(body, TestContext.Current.CancellationToken); + Assert.Equal("12345"u8.ToArray(), bytes); + } + + [Fact(Timeout = 5000)] + public async Task Truncation_across_multiple_data_frames_should_fault_body_reader() + { + var (state, body) = CreateStreamingState(expectedLength: 12); + + state.FeedBody("1234"u8, endStream: false); + state.FeedBody("5678"u8, endStream: true); + + await Assert.ThrowsAsync( + () => ReadToEndAsync(body, TestContext.Current.CancellationToken)); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2StreamStateSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2StreamStateSpec.cs index 244674155..168e6a225 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2StreamStateSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/Http2StreamStateSpec.cs @@ -325,6 +325,37 @@ public void GetOrCreateResponse_should_create_once_and_reuse() Assert.Same(resp2, resp3); } + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.10")] + public void AppendHeader_should_reject_block_exceeding_max_accumulated_bytes() + { + // RFC 9113 §6.10 / CVE-2024-27316: a HEADERS + CONTINUATION sequence must not be allowed to + // accumulate an unbounded header block. The compressed accumulation is capped so the flood is + // rejected before HPACK decode rather than after the buffer has already grown. + var state = new StreamState(); + var chunk = new byte[1024]; + + Assert.Throws(() => + { + for (var i = 0; i < 100; i++) + { + state.AppendHeader(chunk, maxAccumulatedBytes: 8 * 1024); + } + }); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.10")] + public void AppendHeader_within_max_accumulated_bytes_should_not_throw() + { + var state = new StreamState(); + + state.AppendHeader(new byte[4096], maxAccumulatedBytes: 8 * 1024); + state.AppendHeader(new byte[4096], maxAccumulatedBytes: 8 * 1024); + + Assert.Equal(8192, state.GetHeaderSpan().Length); + } + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-5.1")] public void AppendHeader_with_empty_span_should_not_allocate() diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/ResponseRetentionSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/ResponseRetentionSpec.cs index 0f84b838d..1e17bc5f1 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/ResponseRetentionSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Decoder/ResponseRetentionSpec.cs @@ -25,7 +25,7 @@ private static HeadersFrame MakeResponseHeaders(int streamId, bool endStream = t [Trait("RFC", "RFC9113-8.1")] public void StateMachine_should_retain_response_when_rst_stream_no_error_follows_headers() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); @@ -40,7 +40,7 @@ public void StateMachine_should_retain_response_when_rst_stream_no_error_follows headersFrame.WriteTo(ref span); buffer.Length = headersFrame.SerializedSize; - sm.DecodeServerData(new TransportData(buffer)); + sm.DecodeServerData(TransportData.Rent(buffer)); // After headers without END_STREAM, response should be available Assert.Single(ops.Responses); @@ -52,7 +52,7 @@ public void StateMachine_should_retain_response_when_rst_stream_no_error_follows rstFrame.WriteTo(ref rstSpan); rstBuffer.Length = rstFrame.SerializedSize; - sm.DecodeServerData(new TransportData(rstBuffer)); + sm.DecodeServerData(TransportData.Rent(rstBuffer)); // Response should still be retained (still single response) Assert.Single(ops.Responses); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2WindowUpdateSettingsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2WindowUpdateSettingsSpec.cs index b846c5f09..4afe70668 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2WindowUpdateSettingsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2WindowUpdateSettingsSpec.cs @@ -34,24 +34,6 @@ public void FlowController_should_reduce_existing_stream_windows_when_initial_wi Assert.Equal(32768 - 30000, flow.GetSendWindow(1)); } - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113-6.9")] - public void FlowController_should_allow_negative_window_when_initial_window_size_decreases_below_sent() - { - var flow = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535, - initialConnectionSendWindow: 1000000, initialStreamSendWindow: 65535); - flow.InitStreamSendWindow(1); - - flow.OnDataSent(1, 60000); - - var settings = new SettingsFrame([(SettingsParameter.InitialWindowSize, 1024u)]); - flow.OnRemoteSettings(settings); - - // GetSendWindow returns max(0, min(connWindow, streamWindow)), so check that it's 0 - // (stream window is negative, but clamped to 0) - Assert.Equal(0, flow.GetSendWindow(1)); - } - [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.9")] public void FlowController_should_not_affect_new_streams_when_window_is_negative_from_settings_change() @@ -67,26 +49,6 @@ public void FlowController_should_not_affect_new_streams_when_window_is_negative Assert.Equal(1024, flow.GetSendWindow(3)); } - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113-6.9")] - public void FlowController_should_recover_negative_window_when_window_update_received() - { - var flow = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535, - initialConnectionSendWindow: 1000000, initialStreamSendWindow: 65535); - flow.InitStreamSendWindow(1); - flow.OnDataSent(1, 60000); - - var settings = new SettingsFrame([(SettingsParameter.InitialWindowSize, 1024u)]); - flow.OnRemoteSettings(settings); - - // Stream window is now negative (1024 - 60000 = -58976), clamped to 0 - Assert.Equal(0, flow.GetSendWindow(1)); - - // Apply a large window update to recover - flow.OnSendWindowUpdate(1, 70000); - Assert.True(flow.GetSendWindow(1) > 0); - } - [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.5")] public void StreamTracker_should_allow_streams_when_max_concurrent_is_max_value() @@ -98,4 +60,4 @@ public void StreamTracker_should_allow_streams_when_max_concurrent_is_max_value( tracker.OnStreamOpened(id); Assert.True(tracker.CanOpenStream()); } -} +} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientBodyBackpressureSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientBodyBackpressureSpec.cs new file mode 100644 index 000000000..55d879b86 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientBodyBackpressureSpec.cs @@ -0,0 +1,94 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http2; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Client; + +public sealed class Http2ClientBodyBackpressureSpec +{ + private static StreamBodyChunk Chunk(int len) + { + var owner = MemoryPool.Shared.Rent(len); + return new StreamBodyChunk(owner, len); + } + + [Fact(Timeout = 5000)] + public void StreamState_should_track_pending_outbound_bytes() + { + var state = new StreamState(); + + Assert.False(state.HasPendingOutbound); + Assert.Equal(0, state.PendingOutboundBytes); + + state.EnqueueBodyChunk(Chunk(48 * 1024)); + Assert.True(state.HasPendingOutbound); + Assert.Equal(48 * 1024, state.PendingOutboundBytes); + + state.EnqueueBodyChunk(Chunk(32 * 1024)); + Assert.Equal(80 * 1024, state.PendingOutboundBytes); + + state.TryDequeueBodyChunk(out var c1); + c1!.Owner.Dispose(); + Assert.Equal(32 * 1024, state.PendingOutboundBytes); + + state.TryDequeueBodyChunk(out var c2); + c2!.Owner.Dispose(); + Assert.False(state.HasPendingOutbound); + Assert.Equal(0, state.PendingOutboundBytes); + } + + [Fact(Timeout = 5000)] + public void StreamState_should_track_body_drain_lifecycle() + { + var state = new StreamState(); + + Assert.False(state.HasBodyDrain); + Assert.False(state.IsBodyDrainComplete); + + state.MarkBodyDrainActive(); + Assert.True(state.HasBodyDrain); + Assert.False(state.IsBodyDrainComplete); + + state.MarkBodyDrainComplete(); + Assert.True(state.HasBodyDrain); + Assert.True(state.IsBodyDrainComplete); + } + + [Fact(Timeout = 5000)] + public void StreamState_should_reset_body_drain_state() + { + var state = new StreamState(); + state.MarkBodyDrainActive(); + state.MarkBodyDrainComplete(); + + state.Reset(); + + Assert.False(state.HasBodyDrain); + Assert.False(state.IsBodyDrainComplete); + } + + [Fact(Timeout = 5000)] + public void StreamState_should_prepend_body_chunk_before_existing_queue() + { + var state = new StreamState(); + var first = Chunk(100); + var second = Chunk(200); + var prepended = Chunk(50); + + state.EnqueueBodyChunk(first); + state.EnqueueBodyChunk(second); + state.PrependBodyChunk(prepended); + + state.TryDequeueBodyChunk(out var c1); + Assert.Equal(50, c1!.Length); + c1.Owner.Dispose(); + + state.TryDequeueBodyChunk(out var c2); + Assert.Equal(100, c2!.Length); + c2.Owner.Dispose(); + + state.TryDequeueBodyChunk(out var c3); + Assert.Equal(200, c3!.Length); + c3.Owner.Dispose(); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientBodyFastPathSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientBodyFastPathSpec.cs new file mode 100644 index 000000000..6eaa191e7 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientBodyFastPathSpec.cs @@ -0,0 +1,318 @@ +using Servus.Akka.Transport; +using TurboHTTP.Client; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Client; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Client; + +public sealed class Http2ClientBodyFastPathSpec +{ + // A custom HttpContent whose ReadAsStream() returns a publicly-visible MemoryStream, + // exactly the pattern the fast path is designed for (e.g. an in-memory body built by + // serializing into a fresh MemoryStream before sending). + private sealed class VisibleMemoryStreamContent : HttpContent + { + private readonly MemoryStream _ms; + + public VisibleMemoryStreamContent(byte[] body) + { + // new MemoryStream() is publicly visible — TryGetBuffer returns true. + _ms = new MemoryStream(); + _ms.Write(body); + Headers.ContentLength = body.Length; + } + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => + _ms.CopyToAsync(stream); + + protected override bool TryComputeLength(out long length) + { + length = _ms.Length; + return true; + } + + protected override Stream CreateContentReadStream(CancellationToken cancellationToken) + { + _ms.Position = 0; + return _ms; + } + } + + private static Http2ClientSessionManager CreateSession(FakeClientOps ops, int initialSendWindow = 1 * 1024 * 1024) + { + var options = new TurboClientOptions + { + Http2 = new Http2ClientOptions + { + InitialStreamWindowSize = initialSendWindow + } + }; + return new Http2ClientSessionManager(options, ops); + } + + private static List DecodeOutbound(FakeClientOps ops) + { + var frames = new List(); + foreach (var item in ops.Outbound) + { + if (item is TransportData { Buffer: var buf }) + { + // Use a fresh decoder per buffer: the H2 preface magic ("PRI *...") would + // otherwise leave bytes as remainder and corrupt the next frame parse. + // Copy frame data before the decoder is disposed (its working buffer is + // the same TransportBuffer, disposed with the decoder). + var decoder = new FrameDecoder(); + var decoded = decoder.Decode(buf); + foreach (var frame in decoded) + { + // Copy the frame's memory slices so they remain valid after Dispose. + frames.Add(frame is DataFrame df + ? new DataFrame(df.StreamId, df.Data.ToArray(), df.EndStream) + : frame); + } + + decoder.Dispose(); + } + } + + return frames; + } + + private static HttpRequestMessage BuildPost(byte[] body) + { + return new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new VisibleMemoryStreamContent(body) + }; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1")] + public void Visible_MemoryStream_body_should_emit_DATA_frames_inline() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[100]; + new Random(42).NextBytes(body); + var request = BuildPost(body); + + sm.EncodeRequest(request); + + var frames = DecodeOutbound(ops); + var dataFrames = frames.OfType().ToList(); + Assert.NotEmpty(dataFrames); + + var assembled = dataFrames.SelectMany(f => f.Data.ToArray()).ToArray(); + Assert.Equal(body, assembled); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.1")] + public void Fast_path_should_split_body_by_MaxFrameSize() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + // Body larger than the RFC default MaxFrameSize of 16 KiB + var body = new byte[40 * 1024]; + new Random(7).NextBytes(body); + var request = BuildPost(body); + + sm.EncodeRequest(request); + + var frames = DecodeOutbound(ops); + var dataFrames = frames.OfType().ToList(); + + // Each frame payload must not exceed 16 KiB (server default MAX_FRAME_SIZE) + foreach (var frame in dataFrames) + { + Assert.True(frame.Data.Length <= 16 * 1024, + $"DATA frame payload {frame.Data.Length} exceeds MaxFrameSize 16 KiB"); + } + + var assembled = dataFrames.SelectMany(f => f.Data.ToArray()).ToArray(); + Assert.Equal(body, assembled); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Fast_path_should_buffer_remainder_when_send_window_exhausted() + { + // Drive the send window down to 256 bytes by faking a server SETTINGS with + // INITIAL_WINDOW_SIZE = 256. The send window defaults to 65535 (RFC default) + // and only shrinks when the server sends SETTINGS. + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + // Server sends SETTINGS with a tiny INITIAL_WINDOW_SIZE to constrain our send window. + sm.ProcessFrame(new SettingsFrame( + [(SettingsParameter.InitialWindowSize, 256u)], + isAck: false)); + + var body = new byte[1024]; + new Random(3).NextBytes(body); + var request = BuildPost(body); + + sm.EncodeRequest(request); + + var frames = DecodeOutbound(ops); + var dataFrames = frames.OfType().Where(f => f.StreamId == 1).ToList(); + + // Only the windowed portion (256 bytes) should have been emitted immediately + var emittedBytes = dataFrames.Sum(f => f.Data.Length); + Assert.Equal(256, emittedBytes); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Fast_path_should_drain_remainder_on_window_update() + { + // Server starts with a tiny INITIAL_WINDOW_SIZE, then opens the window. + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + sm.ProcessFrame(new SettingsFrame( + [(SettingsParameter.InitialWindowSize, 256u)], + isAck: false)); + + var body = new byte[512]; + new Random(11).NextBytes(body); + var request = BuildPost(body); + + sm.EncodeRequest(request); + + // Grant more window so the remainder can drain + sm.ProcessFrame(new WindowUpdateFrame(streamId: 0, increment: 1024 * 1024)); + sm.ProcessFrame(new WindowUpdateFrame(streamId: 1, increment: 1024 * 1024)); + + var frames = DecodeOutbound(ops); + var dataFrames = frames.OfType().Where(f => f.StreamId == 1).ToList(); + var assembled = dataFrames.SelectMany(f => f.Data.ToArray()).ToArray(); + Assert.Equal(body, assembled); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1")] + public void Non_visible_MemoryStream_should_fall_through_to_encoder_slow_path() + { + // ByteArrayContent.ReadAsStream() returns MemoryStream with TryGetBuffer=false. + // Verify EncodeRequest does not throw and falls back to the encoder. + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) + }; + + // Should not throw — falls back to encoder + var exception = Record.Exception(() => sm.EncodeRequest(request)); + Assert.Null(exception); + } + + // A custom HttpContent that overrides SerializeToStream synchronously (fast path B target). + // Its ReadAsStream() returns a non-visible MemoryStream so the TryGetBuffer fast path A + // does not trigger, exercising the SerializeToStream code path instead. + private sealed class SyncSerializableContent : HttpContent + { + private readonly byte[] _body; + + public SyncSerializableContent(byte[] body) + { + _body = body; + Headers.ContentLength = body.Length; + } + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => + stream.WriteAsync(_body).AsTask(); + + protected override void SerializeToStream(Stream stream, System.Net.TransportContext? context, CancellationToken cancellationToken) => + stream.Write(_body); + + protected override bool TryComputeLength(out long length) + { + length = _body.Length; + return true; + } + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.1")] + public void SerializeToStream_fast_path_should_emit_DATA_frames_for_sync_content() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[200]; + new Random(77).NextBytes(body); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new SyncSerializableContent(body) + }; + + sm.EncodeRequest(request); + + var frames = DecodeOutbound(ops); + var dataFrames = frames.OfType().ToList(); + + Assert.NotEmpty(dataFrames); + + var assembled = dataFrames.SelectMany(f => f.Data.ToArray()).ToArray(); + Assert.Equal(body, assembled); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.1")] + public void SerializeToStream_fast_path_should_split_body_by_MaxFrameSize() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + // Body larger than the RFC default MaxFrameSize of 16 KiB but within the 64 KiB threshold + var body = new byte[40 * 1024]; + new Random(99).NextBytes(body); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new SyncSerializableContent(body) + }; + + sm.EncodeRequest(request); + + var frames = DecodeOutbound(ops); + var dataFrames = frames.OfType().ToList(); + + foreach (var frame in dataFrames) + { + Assert.True(frame.Data.Length <= 16 * 1024, + $"DATA frame payload {frame.Data.Length} exceeds MaxFrameSize 16 KiB"); + } + + var assembled = dataFrames.SelectMany(f => f.Data.ToArray()).ToArray(); + Assert.Equal(body, assembled); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1")] + public void SerializeToStream_fast_path_should_skip_body_exceeding_buffer_threshold() + { + // Body above MaxBufferedRequestBodySize (default 64 KiB) must bypass the fast path + // and be handed off to the async encoder without throwing. + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[128 * 1024]; + new Random(5).NextBytes(body); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new SyncSerializableContent(body) + }; + + var exception = Record.Exception(() => sm.EncodeRequest(request)); + Assert.Null(exception); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientSessionManagerScalingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientSessionManagerScalingSpec.cs new file mode 100644 index 000000000..f0d26ed82 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2ClientSessionManagerScalingSpec.cs @@ -0,0 +1,176 @@ +using Akka.Actor; +using Akka.Event; +using Microsoft.Extensions.Time.Testing; +using Servus.Akka.Transport; +using TurboHTTP.Client; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Client; +using TurboHTTP.Streams.Stages.Client; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Client; + +public sealed class Http2ClientSessionManagerScalingSpec +{ + private sealed class FakeClientStageOperations : IClientStageOperations + { + public List EmittedFrames { get; } = []; + + public void OnResponse(HttpResponseMessage response) { } + + public void OnOutbound(ITransportOutbound item) + { + if (item is TransportData { Buffer: var buf }) + { + var decoder = new FrameDecoder(); + var frames = decoder.Decode(buf); + EmittedFrames.AddRange(frames); + } + } + + public void OnScheduleTimer(string name, TimeSpan duration) { } + + public void OnCancelTimer(string name) { } + + public ILoggingAdapter Log => throw new NotImplementedException(); + + public IActorRef StageActor => throw new NotImplementedException(); + } + + [Fact(Timeout = 5000)] + public void Session_should_emit_measurement_ping_on_inbound_data_when_scaling_enabled() + { + var clock = new FakeTimeProvider(); + var options = new TurboClientOptions + { + Http2 = new Http2ClientOptions + { + InitialStreamWindowSize = 64 * 1024, + MaxStreamWindowSize = 1024 * 1024, + WindowScaleThresholdMultiplier = 1.0, + EnableAdaptiveWindowScaling = true + } + }; + + var ops = new FakeClientStageOperations(); + var sm = new Http2ClientSessionManager(options, ops, clock); + + // Trigger a measurement PING by processing an inbound DATA frame. + var dataFrame = new DataFrame(streamId: 1, data: new byte[100], endStream: false); + sm.ProcessFrame(dataFrame); + + // Expect a PING frame to be emitted. + var pings = ops.EmittedFrames.OfType().ToList(); + Assert.NotEmpty(pings); + + // Verify it's a measurement PING (sentinel payload). + var measurementPing = pings.First(Http2ClientSessionManager.IsRttPing); + Assert.NotNull(measurementPing); + } + + [Fact(Timeout = 5000)] + public void Session_should_record_minrtt_when_measurement_ping_ack_received() + { + var clock = new FakeTimeProvider(); + var options = new TurboClientOptions + { + Http2 = new Http2ClientOptions + { + InitialStreamWindowSize = 64 * 1024, + MaxStreamWindowSize = 1024 * 1024, + WindowScaleThresholdMultiplier = 1.0, + EnableAdaptiveWindowScaling = true + } + }; + + var ops = new FakeClientStageOperations(); + var sm = new Http2ClientSessionManager(options, ops, clock); + + // Process inbound DATA to trigger measurement PING. + var dataFrame = new DataFrame(streamId: 1, data: new byte[100], endStream: false); + sm.ProcessFrame(dataFrame); + + // Find the emitted measurement PING and advance time. + var pings = ops.EmittedFrames.OfType().ToList(); + var measurementPing = pings.First(Http2ClientSessionManager.IsRttPing); + + clock.Advance(TimeSpan.FromMilliseconds(50)); + + // Ack the measurement PING. + var ackFrame = new PingFrame(measurementPing.Data, isAck: true); + sm.ProcessFrame(ackFrame); + + // Verify MinRtt was recorded. + var measuredRtt = sm.MinRttForTest; + Assert.Equal(TimeSpan.FromMilliseconds(50), measuredRtt); + } + + [Fact(Timeout = 5000)] + public void Session_should_not_emit_measurement_ping_when_scaling_disabled() + { + var clock = new FakeTimeProvider(); + var options = new TurboClientOptions + { + Http2 = new Http2ClientOptions + { + EnableAdaptiveWindowScaling = false + } + }; + + var ops = new FakeClientStageOperations(); + var sm = new Http2ClientSessionManager(options, ops, clock); + + // Process inbound DATA. + var dataFrame = new DataFrame(streamId: 1, data: new byte[100], endStream: false); + sm.ProcessFrame(dataFrame); + + // No measurement PINGs should be emitted. + var measurementPings = ops.EmittedFrames + .OfType() + .Where(Http2ClientSessionManager.IsRttPing) + .ToList(); + + Assert.Empty(measurementPings); + Assert.Equal(TimeSpan.Zero, sm.MinRttForTest); + } + + [Fact(Timeout = 5000)] + public void Session_should_not_send_measurement_ping_when_window_at_max() + { + var clock = new FakeTimeProvider(); + var options = new TurboClientOptions + { + Http2 = new Http2ClientOptions + { + InitialStreamWindowSize = 64 * 1024, + MaxStreamWindowSize = 1024 * 1024, + WindowScaleThresholdMultiplier = 1.0, + EnableAdaptiveWindowScaling = true + } + }; + + var ops = new FakeClientStageOperations(); + var sm = new Http2ClientSessionManager(options, ops, clock); + + // Process multiple DATA frames until window grows to max. + for (int i = 0; i < 20; i++) + { + var dataFrame = new DataFrame(streamId: 1, data: new byte[100000], endStream: false); + sm.ProcessFrame(dataFrame); + clock.Advance(TimeSpan.FromMilliseconds(150)); + } + + // Clear the frame list to start fresh. + ops.EmittedFrames.Clear(); + + // When the window is at max, no measurement PINGs should be emitted. + var dataFrame2 = new DataFrame(streamId: 1, data: new byte[100], endStream: false); + sm.ProcessFrame(dataFrame2); + + var measurementPings = ops.EmittedFrames + .OfType() + .Where(Http2ClientSessionManager.IsRttPing) + .ToList(); + + Assert.Empty(measurementPings); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderBaselineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderBaselineSpec.cs index 1bf912331..60d095cc8 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderBaselineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderBaselineSpec.cs @@ -10,7 +10,7 @@ public sealed class Http2EncoderBaselineSpec [Trait("RFC", "RFC9113-3")] public void Http2Encoder_should_encode_get_request_to_headers_frame() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); var frames = encoder.Encode(request, 1); @@ -23,7 +23,7 @@ public void Http2Encoder_should_encode_get_request_to_headers_frame() [Trait("RFC", "RFC9113-3")] public void Http2Encoder_should_assign_stream_id_to_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var frames = encoder.Encode(request, 5); @@ -35,7 +35,7 @@ public void Http2Encoder_should_assign_stream_id_to_request() [Trait("RFC", "RFC9113-8.1.2.1")] public void Http2Encoder_should_include_pseudo_headers_in_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/resource"); var headerBlock = encoder.EncodeToHpackBlock(request); @@ -51,7 +51,7 @@ public void Http2Encoder_should_include_pseudo_headers_in_request() [Trait("RFC", "RFC9113-8.1.1")] public void Http2Encoder_should_set_method_to_get_for_get_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var headerBlock = encoder.EncodeToHpackBlock(request); @@ -64,7 +64,7 @@ public void Http2Encoder_should_set_method_to_get_for_get_request() [Trait("RFC", "RFC9113-8.1.1")] public void Http2Encoder_should_set_method_to_post_for_post_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/"); var headerBlock = encoder.EncodeToHpackBlock(request); @@ -77,7 +77,7 @@ public void Http2Encoder_should_set_method_to_post_for_post_request() [Trait("RFC", "RFC9113-8.1.1")] public void Http2Encoder_should_set_path_from_uri() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/api/resource"); var headerBlock = encoder.EncodeToHpackBlock(request); @@ -90,7 +90,7 @@ public void Http2Encoder_should_set_path_from_uri() [Trait("RFC", "RFC9113-8.1.1")] public void Http2Encoder_should_set_scheme_to_http() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var headerBlock = encoder.EncodeToHpackBlock(request); @@ -103,7 +103,7 @@ public void Http2Encoder_should_set_scheme_to_http() [Trait("RFC", "RFC9113-8.1.1")] public void Http2Encoder_should_set_scheme_to_https() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); var headerBlock = encoder.EncodeToHpackBlock(request); @@ -116,7 +116,7 @@ public void Http2Encoder_should_set_scheme_to_https() [Trait("RFC", "RFC9113-8.1.1")] public void Http2Encoder_should_set_authority_from_uri() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://api.example.com:8080/"); var headerBlock = encoder.EncodeToHpackBlock(request); @@ -129,7 +129,7 @@ public void Http2Encoder_should_set_authority_from_uri() [Trait("RFC", "RFC9113-8.2.2")] public void Http2Encoder_should_encode_regular_headers() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); request.Headers.Add("User-Agent", "TestClient/1.0"); @@ -143,7 +143,7 @@ public void Http2Encoder_should_encode_regular_headers() [Trait("RFC", "RFC9113-8.1")] public void Http2Encoder_should_produce_headers_frame_with_end_stream_for_get() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var frames = encoder.Encode(request, 1); @@ -156,7 +156,7 @@ public void Http2Encoder_should_produce_headers_frame_with_end_stream_for_get() [Trait("RFC", "RFC9113-8.1.2.1")] public void Http2Encoder_should_produce_headers_without_end_stream_for_post() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/") { Content = new StringContent("body"), diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderRfcTaggedSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderRfcTaggedSpec.cs index 4a09f0920..dc9ddb48c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderRfcTaggedSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2EncoderRfcTaggedSpec.cs @@ -10,7 +10,7 @@ public sealed class Http2EncoderRfcTaggedSpec [Trait("RFC", "RFC9113-5.1")] public void Http2Encoder_should_set_end_stream_on_headers_for_stateless_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var frames = encoder.Encode(request, 1); @@ -23,7 +23,7 @@ public void Http2Encoder_should_set_end_stream_on_headers_for_stateless_request( [Trait("RFC", "RFC9113-5.1")] public void Http2Encoder_should_not_set_end_stream_on_headers_for_request_with_body() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/") { Content = new StringContent("data"), @@ -66,7 +66,7 @@ public void Http2Encoder_should_encode_with_huffman_when_enabled() [Trait("RFC", "RFC9113-6.2")] public void Http2Encoder_should_set_end_headers_flag_on_headers_frame() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var frames = encoder.Encode(request, 1); @@ -79,7 +79,7 @@ public void Http2Encoder_should_set_end_headers_flag_on_headers_frame() [Trait("RFC", "RFC9113-8.3")] public void Http2Encoder_should_lower_case_header_names() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); request.Headers.TryAddWithoutValidation("X-Custom-Header", "value"); @@ -93,7 +93,7 @@ public void Http2Encoder_should_lower_case_header_names() [Trait("RFC", "RFC9113-8.2.2")] public void Http2Encoder_should_strip_connection_specific_headers() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); request.Headers.TryAddWithoutValidation("connection", "keep-alive"); @@ -107,7 +107,7 @@ public void Http2Encoder_should_strip_connection_specific_headers() [Trait("RFC", "RFC9113-5.1.1")] public void Http2Encoder_should_use_odd_stream_ids() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var frames = encoder.Encode(request, 1); @@ -119,7 +119,7 @@ public void Http2Encoder_should_use_odd_stream_ids() [Trait("RFC", "RFC9113-6.9")] public void Http2Encoder_should_maintain_flow_control_window() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/") { Content = new StringContent("data"), @@ -134,7 +134,7 @@ public void Http2Encoder_should_maintain_flow_control_window() [Trait("RFC", "RFC9113-8.1.2.1")] public void Http2Encoder_should_prefix_pseudo_headers() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var headerBlock = encoder.EncodeToHpackBlock(request); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2RequestEncoderFrameSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2RequestEncoderFrameSpec.cs index 76fb7f631..f7930a3e6 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2RequestEncoderFrameSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/Http2RequestEncoderFrameSpec.cs @@ -10,7 +10,7 @@ public sealed class Http2RequestEncoderFrameSpec [Trait("RFC", "RFC9113-8.1")] public void Http2RequestEncoder_should_produce_headers_frame_with_end_stream_when_encoding_get_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); var frames = encoder.Encode(request, 1); @@ -27,7 +27,7 @@ public void Http2RequestEncoder_should_produce_headers_frame_with_end_stream_whe [Trait("RFC", "RFC9113-8.1")] public void Http2RequestEncoder_should_produce_headers_without_end_stream_when_encoding_post_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/submit") { Content = new StringContent("hello world"), @@ -46,7 +46,7 @@ public void Http2RequestEncoder_should_produce_headers_without_end_stream_when_e [Trait("RFC", "RFC9113-8.3.1")] public void Http2RequestEncoder_should_contain_pseudo_headers_when_encoding_get_request_header_block() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/v1/data?q=1"); var headers = new HpackDecoder().Decode(encoder.EncodeToHpackBlock(request)); @@ -61,7 +61,7 @@ public void Http2RequestEncoder_should_contain_pseudo_headers_when_encoding_get_ [Trait("RFC", "RFC9113-8.3.1")] public void Http2RequestEncoder_should_include_query_in_path_when_encoding_request_with_query() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/search?term=foo&page=2"); var headers = new HpackDecoder().Decode(encoder.EncodeToHpackBlock(request)); @@ -73,7 +73,7 @@ public void Http2RequestEncoder_should_include_query_in_path_when_encoding_reque [Trait("RFC", "RFC9113-8.2.2")] public void Http2RequestEncoder_should_strip_connection_headers_when_encoding() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); request.Headers.TryAddWithoutValidation("connection", "keep-alive"); request.Headers.TryAddWithoutValidation("x-custom", "value"); @@ -88,8 +88,9 @@ public void Http2RequestEncoder_should_strip_connection_headers_when_encoding() [Trait("RFC", "RFC9113-6.10")] public void Http2RequestEncoder_should_use_continuation_frames_when_header_block_larger_than_max_frame_size() { - // Use a tiny maxFrameSize to force continuation - var encoder = new Http2ClientEncoder(useHuffman: false, maxFrameSize: 30); + // Force a tiny send frame size via server settings so the header block fragments. + var encoder = new Http2ClientEncoder(useHuffman: false); + encoder.ApplyServerSettings([(SettingsParameter.MaxFrameSize, 30u)]); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); request.Headers.TryAddWithoutValidation("x-long-header", new string('a', 100)); @@ -117,7 +118,7 @@ public void Http2RequestEncoder_should_use_continuation_frames_when_header_block [Trait("RFC", "RFC9113-5.1.1")] public void Http2RequestEncoder_should_have_same_stream_id_on_all_frames_when_encoding_post_request() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/data") { Content = new ByteArrayContent([1, 2, 3, 4]), @@ -132,7 +133,7 @@ public void Http2RequestEncoder_should_have_same_stream_id_on_all_frames_when_en [Trait("RFC", "RFC9113-8.1")] public void Http2RequestEncoder_should_produce_headers_frame_with_end_headers() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var frames = encoder.Encode(request, 1); @@ -145,7 +146,7 @@ public void Http2RequestEncoder_should_produce_headers_frame_with_end_headers() [Trait("RFC", "RFC9113-8.1")] public void Http2RequestEncoder_should_encode_multiple_requests_with_increasing_stream_ids() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request1 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/1"); var request2 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/2"); @@ -163,7 +164,7 @@ public void Http2RequestEncoder_should_encode_multiple_requests_with_increasing_ [Trait("RFC", "RFC9113-6.9")] public void Http2RequestEncoder_should_apply_server_settings_max_frame_size() { - var encoder = new Http2ClientEncoder(maxFrameSize: 16384); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); // Before settings change @@ -180,11 +181,31 @@ public void Http2RequestEncoder_should_apply_server_settings_max_frame_size() Assert.NotEmpty(frames2); } + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-4.2")] + public void Http2RequestEncoder_should_default_send_max_frame_size_to_rfc_minimum() + { + var encoder = new Http2ClientEncoder(useHuffman: true); + + Assert.Equal(16 * 1024, encoder.MaxFrameSize); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.5.2")] + public void Http2RequestEncoder_should_raise_send_max_frame_size_when_server_advertises_larger() + { + var encoder = new Http2ClientEncoder(useHuffman: true); + + encoder.ApplyServerSettings([(SettingsParameter.MaxFrameSize, 32768u)]); + + Assert.Equal(32768, encoder.MaxFrameSize); + } + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.5")] public void Http2RequestEncoder_should_apply_server_settings_header_table_size() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); encoder.ApplyServerSettings([(SettingsParameter.HeaderTableSize, 2048)]); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); @@ -198,7 +219,7 @@ public void Http2RequestEncoder_should_apply_server_settings_header_table_size() [Trait("RFC", "RFC9113-6.9")] public void Http2RequestEncoder_should_apply_server_settings_initial_window_size() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); // Apply new initial window size encoder.ApplyServerSettings([(SettingsParameter.InitialWindowSize, 32768)]); @@ -216,7 +237,7 @@ public void Http2RequestEncoder_should_apply_server_settings_initial_window_size [Trait("RFC", "RFC9113-5.1.1")] public void Http2RequestEncoder_should_reset_hpack_encoder() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); var frames1 = encoder.Encode(request, 1); @@ -233,7 +254,7 @@ public void Http2RequestEncoder_should_reset_hpack_encoder() [Trait("RFC", "RFC9113-6.5")] public void Http2RequestEncoder_should_throw_when_stream_id_negative() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var ex = Assert.Throws(() => encoder.Encode(request, -1)); @@ -244,7 +265,7 @@ public void Http2RequestEncoder_should_throw_when_stream_id_negative() [Trait("RFC", "RFC9113-8.3.1")] public void Http2RequestEncoder_should_throw_when_request_uri_null() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, (string)null!); Assert.Throws(() => encoder.Encode(request, 1)); @@ -254,7 +275,8 @@ public void Http2RequestEncoder_should_throw_when_request_uri_null() [Trait("RFC", "RFC9113-6.10")] public void Http2RequestEncoder_should_handle_large_header_block_fragmentation() { - var encoder = new Http2ClientEncoder(useHuffman: false, maxFrameSize: 100); + var encoder = new Http2ClientEncoder(useHuffman: false); + encoder.ApplyServerSettings([(SettingsParameter.MaxFrameSize, 100u)]); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/path"); request.Headers.TryAddWithoutValidation("x-large-1", new string('a', 200)); request.Headers.TryAddWithoutValidation("x-large-2", new string('b', 200)); @@ -271,7 +293,7 @@ public void Http2RequestEncoder_should_handle_large_header_block_fragmentation() [Trait("RFC", "RFC9113-6.9")] public void Http2RequestEncoder_should_respect_connection_window_for_post_body() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var largeBody = new byte[32768]; // Larger than default window var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/data") { @@ -291,7 +313,7 @@ public void Http2RequestEncoder_should_respect_connection_window_for_post_body() [Trait("RFC", "RFC9113-2.3.2")] public void Http2RequestEncoder_should_lowercase_header_names() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); request.Headers.TryAddWithoutValidation("X-Custom-Header", "value"); request.Headers.TryAddWithoutValidation("CONTENT-TYPE", "text/plain"); @@ -300,7 +322,7 @@ public void Http2RequestEncoder_should_lowercase_header_names() Assert.Contains(headers, h => h.Name == "x-custom-header"); // Note: custom headers, not pseudo-headers - Assert.All(headers.Where(h => !h.Name.StartsWith(":")), h => + Assert.All(headers.Where(h => !h.Name.StartsWith(':')), h => Assert.Equal(h.Name, h.Name.ToLowerInvariant())); } @@ -308,7 +330,7 @@ public void Http2RequestEncoder_should_lowercase_header_names() [Trait("RFC", "RFC9113-8.2")] public void Http2RequestEncoder_should_strip_all_forbidden_headers() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); request.Headers.TryAddWithoutValidation("connection", "upgrade"); request.Headers.TryAddWithoutValidation("keep-alive", "timeout=5"); @@ -334,7 +356,7 @@ public void Http2RequestEncoder_should_strip_all_forbidden_headers() [Trait("RFC", "RFC9113-8.1")] public void Http2RequestEncoder_should_produce_headers_without_end_stream_for_post_with_empty_body() { - var encoder = new Http2ClientEncoder(); + var encoder = new Http2ClientEncoder(useHuffman: true); var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/"); request.Content = new ByteArrayContent([]); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2GoAwayComplianceSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2GoAwayComplianceSpec.cs index 6a3a228bb..0e0f1f9ce 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2GoAwayComplianceSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2GoAwayComplianceSpec.cs @@ -37,12 +37,12 @@ private static TransportBuffer SerializeFrame(Http2Frame frame) [Trait("RFC", "RFC9113-6.8")] public void StateMachine_should_not_accept_requests_when_goaway_received() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); var goaway = new GoAwayFrame(5, Http2ErrorCode.NoError); - sm.DecodeServerData(new TransportData(SerializeFrame(goaway))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(goaway))); Assert.False(sm.CanAcceptRequest); } @@ -62,19 +62,6 @@ public void FlowController_should_preserve_stream_windows_when_goaway_received() Assert.Equal(65535, flow.GetSendWindow(3)); } - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113-6.8")] - public void FlowController_should_accept_window_update_on_existing_stream_after_goaway() - { - var flow = new FlowController(65535, 65535, initialConnectionSendWindow: 100000); - flow.InitStreamSendWindow(1); - flow.OnGoAway(); - - flow.OnSendWindowUpdate(1, 10000); - - Assert.Equal(75535, flow.GetSendWindow(1)); - } - [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.8")] public void HpackDecoder_should_maintain_dynamic_table_state_across_goaway() diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineKeepAliveSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineKeepAliveSpec.cs index 330e9318d..7adfa4fbc 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineKeepAliveSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineKeepAliveSpec.cs @@ -24,7 +24,7 @@ private static TurboClientOptions MakeConfig() [Trait("RFC", "RFC9113-6.7")] public void OnTimerFired_should_emit_ping_frame_on_keepalive_timer() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -38,7 +38,7 @@ public void OnTimerFired_should_emit_ping_frame_on_keepalive_timer() [Trait("RFC", "RFC9113-6.7")] public void OnTimerFired_should_not_emit_duplicate_ping_when_awaiting_ack() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -53,7 +53,7 @@ public void OnTimerFired_should_not_emit_duplicate_ping_when_awaiting_ack() [Trait("RFC", "RFC9113-6.7")] public void OnTimerFired_should_not_close_when_timeout_not_elapsed() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs index 01931c40c..ebabe2605 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs @@ -52,7 +52,7 @@ private static (HttpRequestMessage Request, PendingRequest Pending) MakeTrackedG [Trait("RFC", "RFC9113-6.8")] public void DecodeServerData_should_start_reconnect_on_disconnect_with_inflight() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet("/a")); @@ -70,7 +70,7 @@ public void DecodeServerData_should_start_reconnect_on_disconnect_with_inflight( [Trait("RFC", "RFC9113-6.8")] public void DecodeServerData_should_not_replay_non_idempotent_requests() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet("/a")); // stream 1 @@ -78,7 +78,7 @@ public void DecodeServerData_should_not_replay_non_idempotent_requests() ops.Outbound.Clear(); var goaway = new GoAwayFrame(3, Http2ErrorCode.NoError); - sm.DecodeServerData(new TransportData(SerializeFrame(goaway))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(goaway))); Assert.True(sm.IsReconnecting); Assert.Equal(1, sm.ReconnectBufferCount); @@ -88,7 +88,7 @@ public void DecodeServerData_should_not_replay_non_idempotent_requests() [Trait("RFC", "RFC9113-6.8")] public void DecodeServerData_should_replay_requests_on_connection_restored() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet("/a")); @@ -107,7 +107,7 @@ public void DecodeServerData_should_replay_requests_on_connection_restored() [Trait("RFC", "RFC9113-6.8")] public void DecodeServerData_should_set_CanAcceptRequest_false_when_reconnecting() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); @@ -121,7 +121,7 @@ public void DecodeServerData_should_set_CanAcceptRequest_false_when_reconnecting [Trait("RFC", "RFC9113-6.8")] public void DecodeServerData_should_fail_when_max_reconnect_exceeded() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(maxReconnect: 1), ops); sm.PreStart(); var (req, pending) = MakeTrackedGet(); @@ -138,7 +138,7 @@ public void DecodeServerData_should_fail_when_max_reconnect_exceeded() [Trait("RFC", "RFC9113-6.8")] public void DecodeServerData_should_emit_new_connect_when_reconnect_under_limit() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(maxReconnect: 3), ops); sm.PreStart(); sm.OnRequest(MakeGet()); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineSpec.cs index cb0440804..724e49c9d 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineSpec.cs @@ -1,5 +1,6 @@ using Servus.Akka.Transport; using TurboHTTP.Client; +using TurboHTTP.Internal; using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Client; using TurboHTTP.Protocol.Syntax.Http2.Hpack; @@ -83,7 +84,7 @@ private static TransportBuffer SerializeFrames(params Http2Frame[] frames) [Trait("RFC", "RFC9113-3.4")] public void PreStart_should_not_emit_preface() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); @@ -95,7 +96,7 @@ public void PreStart_should_not_emit_preface() [Trait("RFC", "RFC9113-8.3")] public void OnRequest_should_emit_preface_and_headers_frame_on_first_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -110,12 +111,12 @@ public void OnRequest_should_emit_preface_and_headers_frame_on_first_request() [Trait("RFC", "RFC9113-8.3")] public void OnRequest_should_reject_when_goaway_received() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); var goaway = new GoAwayFrame(0, Http2ErrorCode.NoError); - sm.DecodeServerData(new TransportData(SerializeFrame(goaway))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(goaway))); sm.OnRequest(MakeGet()); @@ -126,7 +127,7 @@ public void OnRequest_should_reject_when_goaway_received() [Trait("RFC", "RFC9113-8.3")] public void OnRequest_should_set_endpoint_on_first_request() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); @@ -141,7 +142,7 @@ public void OnRequest_should_set_endpoint_on_first_request() [Trait("RFC", "RFC9113-5.1")] public void OnRequest_should_emit_data_frame_when_request_has_body() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -157,7 +158,7 @@ public void OnRequest_should_emit_data_frame_when_request_has_body() [Trait("RFC", "RFC9113-5.1.1")] public void OnRequest_should_allocate_incremented_stream_ids() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -174,13 +175,13 @@ public void OnRequest_should_allocate_incremented_stream_ids() [Trait("RFC", "RFC9113-4")] public void DecodeServerData_should_process_settings_frame() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); var settings = new SettingsFrame([]); - sm.DecodeServerData(new TransportData(SerializeFrame(settings))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(settings))); Assert.NotEmpty(ops.Outbound.OfType()); } @@ -189,7 +190,7 @@ public void DecodeServerData_should_process_settings_frame() [Trait("RFC", "RFC9113-6.9")] public void DecodeServerData_should_produce_response_from_headers_and_data() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); @@ -197,7 +198,7 @@ public void DecodeServerData_should_produce_response_from_headers_and_data() var headers = MakeResponseHeaders(1, endStream: false, endHeaders: true); var data = MakeData(1, [1, 2, 3], endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrames(headers, data))); + sm.DecodeServerData(TransportData.Rent(SerializeFrames(headers, data))); Assert.Single(ops.Responses); } @@ -206,14 +207,14 @@ public void DecodeServerData_should_produce_response_from_headers_and_data() [Trait("RFC", "RFC9113-6.2")] public void DecodeServerData_should_complete_response_on_headers_with_endstream() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); ops.Outbound.Clear(); var headers = MakeResponseHeaders(1); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); Assert.Single(ops.Responses); } @@ -222,7 +223,7 @@ public void DecodeServerData_should_complete_response_on_headers_with_endstream( [Trait("RFC", "RFC9113-6.2")] public void DecodeServerData_should_accumulate_headers_without_endheaders() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); @@ -236,7 +237,7 @@ public void DecodeServerData_should_accumulate_headers_without_endheaders() var split = hpack.Length / 2; var partial = new HeadersFrame(1, hpack.Slice(0, split), endHeaders: false, endStream: false); - sm.DecodeServerData(new TransportData(SerializeFrame(partial))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(partial))); Assert.Empty(ops.Responses); } @@ -245,7 +246,7 @@ public void DecodeServerData_should_accumulate_headers_without_endheaders() [Trait("RFC", "RFC9113-6.10")] public void DecodeServerData_should_handle_continuation_frame() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); @@ -260,13 +261,13 @@ public void DecodeServerData_should_handle_continuation_frame() var split = hpackSize / 2; var headers = new HeadersFrame(1, fullHpack[..split], endHeaders: false, endStream: false); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); var cont = new ContinuationFrame(1, fullHpack[split..], endHeaders: true); - sm.DecodeServerData(new TransportData(SerializeFrame(cont))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(cont))); var data = MakeData(1, [], endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrame(data))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(data))); Assert.Single(ops.Responses); } @@ -275,55 +276,102 @@ public void DecodeServerData_should_handle_continuation_frame() [Trait("RFC", "RFC9113-6.3")] public void DecodeServerData_should_handle_rst_stream_frame() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); var rst = new RstStreamFrame(1, Http2ErrorCode.Cancel); - sm.DecodeServerData(new TransportData(SerializeFrame(rst))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(rst))); Assert.Empty(ops.Responses); } + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.4.1")] + public void DecodeServerData_should_disconnect_on_connection_protocol_error() + { + // RFC 9113 §5.4.1 / §6.10: a connection-fatal framing error must tear down the connection, not + // be swallowed and decoding continued against a desynchronized decoder. A bare CONTINUATION with + // no preceding HEADERS is such an error. + var ops = new FakeClientOps(); + var sm = new Http2ClientStateMachine(MakeConfig(), ops); + sm.PreStart(); + sm.OnRequest(MakeGet()); + ops.Outbound.Clear(); + + var badFrame = SerializeFrame(new ContinuationFrame(1, ReadOnlyMemory.Empty, endHeaders: true)); + sm.DecodeServerData(TransportData.Rent(badFrame)); + + Assert.Contains(ops.Outbound, o => o is DisconnectTransport); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public async Task DecodeServerData_should_fail_in_flight_request_when_stream_is_reset() + { + // RFC 9113 §8.1: a RST_STREAM before any response must fail the waiting caller, not leave its + // Task hanging until an unrelated timeout. The error code is surfaced to the caller. + var ops = new FakeClientOps(); + var sm = new Http2ClientStateMachine(MakeConfig(), ops); + sm.PreStart(); + + var request = MakeGet(); + var pending = PendingRequest.Rent(); + var version = pending.Version; + request.Options.Set(OptionsKey.Key, pending); + request.Options.Set(OptionsKey.VersionKey, version); + var valueTask = new ValueTask(pending, version); + + sm.OnRequest(request); + + var rst = new RstStreamFrame(1, Http2ErrorCode.RefusedStream); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(rst))); + + Assert.True(valueTask.IsFaulted); + await Assert.ThrowsAsync(async () => await valueTask); + + PendingRequest.Return(pending); + } + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.9")] public void DecodeServerData_should_handle_window_update_on_connection() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); var win = new WindowUpdateFrame(0, 16384); - sm.DecodeServerData(new TransportData(SerializeFrame(win))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(win))); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.9")] public void DecodeServerData_should_handle_window_update_on_stream() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); ops.Outbound.Clear(); var win = new WindowUpdateFrame(1, 8192); - sm.DecodeServerData(new TransportData(SerializeFrame(win))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(win))); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.7")] public void DecodeServerData_should_respond_to_ping_with_ack() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); var ping = new PingFrame(new byte[8], isAck: false); - sm.DecodeServerData(new TransportData(SerializeFrame(ping))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(ping))); Assert.Single(ops.Outbound.OfType()); } @@ -332,13 +380,13 @@ public void DecodeServerData_should_respond_to_ping_with_ack() [Trait("RFC", "RFC9113-6.7")] public void DecodeServerData_should_ignore_ping_ack() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); ops.Outbound.Clear(); var pong = new PingFrame(new byte[8], isAck: true); - sm.DecodeServerData(new TransportData(SerializeFrame(pong))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(pong))); Assert.Empty(ops.Outbound); } @@ -347,14 +395,14 @@ public void DecodeServerData_should_ignore_ping_ack() [Trait("RFC", "RFC9113-6.8")] public void DecodeServerData_should_trigger_reconnect_on_goaway_with_inflight() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); ops.Outbound.Clear(); var goaway = new GoAwayFrame(0, Http2ErrorCode.NoError); - sm.DecodeServerData(new TransportData(SerializeFrame(goaway))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(goaway))); Assert.True(sm.IsReconnecting); Assert.Single(ops.Outbound, item => item is ConnectTransport); @@ -364,8 +412,11 @@ public void DecodeServerData_should_trigger_reconnect_on_goaway_with_inflight() [Trait("RFC", "RFC9113-6.9")] public void DecodeServerData_should_disconnect_when_connection_flow_control_violated() { - var ops = new FakeOps(); - var sm = new Http2ClientStateMachine(MakeConfig(), ops); + var ops = new FakeClientOps(); + // Advertise a MAX_FRAME_SIZE large enough that the 100 KB frame is legal at the frame layer, + // so this exercises flow-control enforcement (100000 > 65535 stream window) rather than the + // separate MAX_FRAME_SIZE check. + var sm = new Http2ClientStateMachine(MakeConfig(maxFrameSize: 128 * 1024), ops); sm.PreStart(); sm.OnRequest(MakeGet()); ops.Outbound.Clear(); @@ -373,7 +424,7 @@ public void DecodeServerData_should_disconnect_when_connection_flow_control_viol var headers = MakeResponseHeaders(1, endStream: false, endHeaders: true); var largeData = new byte[100000]; var data = new DataFrame(1, largeData, endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrames(headers, data))); + sm.DecodeServerData(TransportData.Rent(SerializeFrames(headers, data))); Assert.Single(ops.Outbound, o => o is DisconnectTransport); } @@ -382,7 +433,7 @@ public void DecodeServerData_should_disconnect_when_connection_flow_control_viol [Trait("RFC", "RFC9113-8.1")] public void DecodeServerData_should_correlate_request_with_response() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); @@ -391,7 +442,7 @@ public void DecodeServerData_should_correlate_request_with_response() ops.Outbound.Clear(); var headers = MakeResponseHeaders(1); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); var response = Assert.Single(ops.Responses); Assert.NotNull(response.RequestMessage); @@ -402,7 +453,7 @@ public void DecodeServerData_should_correlate_request_with_response() [Trait("RFC", "RFC9113-5.4")] public void DecodeServerData_should_handle_multiple_concurrent_streams() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); @@ -411,10 +462,10 @@ public void DecodeServerData_should_handle_multiple_concurrent_streams() ops.Outbound.Clear(); var headers3 = MakeResponseHeaders(3); - sm.DecodeServerData(new TransportData(SerializeFrame(headers3))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers3))); var headers1 = MakeResponseHeaders(1); - sm.DecodeServerData(new TransportData(SerializeFrame(headers1))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers1))); Assert.Equal(2, ops.Responses.Count); } @@ -423,7 +474,7 @@ public void DecodeServerData_should_handle_multiple_concurrent_streams() [Trait("RFC", "RFC9113-5.1.2")] public void CanAcceptRequest_should_respect_max_concurrent_streams() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(maxConcurrentStreams: 2), ops); sm.PreStart(); @@ -437,13 +488,13 @@ public void CanAcceptRequest_should_respect_max_concurrent_streams() [Trait("RFC", "RFC9113-8.1")] public void DecodeServerData_should_decode_1xx_status_codes() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); var headers = MakeResponseHeaders(1, "100", endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); var response = Assert.Single(ops.Responses); Assert.Equal(100, (int)response.StatusCode); @@ -453,13 +504,13 @@ public void DecodeServerData_should_decode_1xx_status_codes() [Trait("RFC", "RFC9113-8.1")] public void DecodeServerData_should_decode_4xx_status_codes() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); var headers = MakeResponseHeaders(1, "404", endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); var response = Assert.Single(ops.Responses); Assert.Equal(404, (int)response.StatusCode); @@ -469,13 +520,13 @@ public void DecodeServerData_should_decode_4xx_status_codes() [Trait("RFC", "RFC9113-8.1")] public void DecodeServerData_should_decode_5xx_status_codes() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); var headers = MakeResponseHeaders(1, "500", endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); var response = Assert.Single(ops.Responses); Assert.Equal(500, (int)response.StatusCode); @@ -485,12 +536,12 @@ public void DecodeServerData_should_decode_5xx_status_codes() [Trait("RFC", "RFC9113-6.10")] public void DecodeServerData_should_absorb_data_for_unknown_stream() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); var data = new DataFrame(999, new byte[10], endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrame(data))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(data))); Assert.True(true); } @@ -499,40 +550,55 @@ public void DecodeServerData_should_absorb_data_for_unknown_stream() [Trait("RFC", "RFC9113-6.2")] public void DecodeServerData_should_absorb_continuation_for_unknown_stream() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); var data = new DataFrame(999, new byte[10], endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrame(data))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(data))); Assert.True(true); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.2")] - public void DecodeServerData_should_accumulate_response_body_across_multiple_frames() + public void DecodeServerData_should_stream_response_body_via_bridged_reader() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); + // Send HEADERS + first DATA in one batch (QueuedBodyReader has fixed capacity, + // so the consumer must read between enqueues — split into separate messages). var headers = MakeResponseHeaders(1, endStream: false, endHeaders: true); var data1 = MakeData(1, [1, 2, 3], endStream: false); - var data2 = MakeData(1, [4, 5, 6], endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrames(headers, data1, data2))); + sm.DecodeServerData(TransportData.Rent(SerializeFrames(headers, data1))); var response = Assert.Single(ops.Responses); var body = response.Content.ReadAsStream(TestContext.Current.CancellationToken); Assert.NotNull(body); + + // Consume the first chunk so the bridged reader is ready for the next Supply + var buf = new byte[3]; + var read = body.Read(buf, 0, buf.Length); + Assert.Equal(3, read); + Assert.Equal(new byte[] { 1, 2, 3 }, buf); + + // Now send the second DATA frame with END_STREAM + var data2 = MakeData(1, [4, 5, 6], endStream: true); + sm.DecodeServerData(TransportData.Rent(SerializeFrames(data2))); + + read = body.Read(buf, 0, buf.Length); + Assert.Equal(3, read); + Assert.Equal(new byte[] { 4, 5, 6 }, buf); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-3.1")] public void Endpoint_should_be_initialized_default() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); Assert.Equal(default, sm.Endpoint); @@ -542,7 +608,7 @@ public void Endpoint_should_be_initialized_default() [Trait("RFC", "RFC9113-5.1")] public void HasInFlightRequests_should_be_true_when_requests_pending() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); @@ -554,13 +620,13 @@ public void HasInFlightRequests_should_be_true_when_requests_pending() [Trait("RFC", "RFC9113-5.1")] public void HasInFlightRequests_should_be_false_after_response() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); var headers = MakeResponseHeaders(1); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); Assert.False(sm.HasInFlightRequests); } @@ -569,7 +635,7 @@ public void HasInFlightRequests_should_be_false_after_response() [Trait("RFC", "RFC9113-8.1")] public void DecodeServerData_should_preserve_response_headers() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var sm = new Http2ClientStateMachine(MakeConfig(), ops); sm.PreStart(); sm.OnRequest(MakeGet()); @@ -581,7 +647,7 @@ public void DecodeServerData_should_preserve_response_headers() ("cache-control", "max-age=3600") ]); var headers = new HeadersFrame(1, hpack, endHeaders: true, endStream: true); - sm.DecodeServerData(new TransportData(SerializeFrame(headers))); + sm.DecodeServerData(TransportData.Rent(SerializeFrame(headers))); var response = Assert.Single(ops.Responses); Assert.True(response.Content.Headers.ContentType is not null); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/FlowControllerAdaptiveScalingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/FlowControllerAdaptiveScalingSpec.cs new file mode 100644 index 000000000..9df9a46e4 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/FlowControllerAdaptiveScalingSpec.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Time.Testing; +using TurboHTTP.Protocol.Syntax.Http2; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2; + +public sealed class FlowControllerAdaptiveScalingSpec +{ + private const int Start = 64 * 1024; + private const int Cap = 16 * 1024 * 1024; + private const int ConnWindow = 64 * 1024 * 1024; + + private static FlowController NewScaling(FakeTimeProvider clock) => + new(ConnWindow, Start, new WindowScaler(Cap, 1.0), clock); + + [Fact(Timeout = 5000)] + public void FlowController_should_grow_stream_window_when_saturated() + { + var clock = new FakeTimeProvider(); + var fc = NewScaling(clock); + + // Establish a 100ms min-RTT via measurement PING. + fc.OnMeasurementPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(100)); + fc.OnMeasurementPingAck(); + + fc.OnInboundData(1, Start / 2); + clock.Advance(TimeSpan.FromMilliseconds(10)); + var result = fc.OnInboundData(1, Start / 2); + + Assert.True(result.Success); + Assert.NotNull(result.StreamWindowUpdate); + Assert.True(result.StreamWindowUpdate!.Value.Increment > Start); + Assert.Equal(Start * 2, fc.CurrentStreamWindow); + } + + [Fact(Timeout = 5000)] + public void FlowController_should_not_grow_when_min_rtt_unknown() + { + var clock = new FakeTimeProvider(); + var fc = NewScaling(clock); + + fc.OnInboundData(1, Start / 2); + clock.Advance(TimeSpan.FromMilliseconds(10)); + fc.OnInboundData(1, Start / 2); + + Assert.Equal(Start, fc.CurrentStreamWindow); + } + + [Fact(Timeout = 5000)] + public void FlowController_should_behave_identically_to_static_when_no_scaler() + { + var fc = new FlowController(ConnWindow, Start); + + fc.OnInboundData(1, Start / 2); + var result = fc.OnInboundData(1, Start / 2); + + Assert.Equal(Start, fc.CurrentStreamWindow); + Assert.NotNull(result.StreamWindowUpdate); + Assert.Equal(Start / 2, result.StreamWindowUpdate!.Value.Increment); + } + + [Fact(Timeout = 5000)] + public void FlowController_reset_should_clear_scaling_state() + { + var clock = new FakeTimeProvider(); + var fc = NewScaling(clock); + + // Establish a 100ms min-RTT via measurement PING. + fc.OnMeasurementPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(100)); + fc.OnMeasurementPingAck(); + + fc.OnInboundData(1, Start / 2); + clock.Advance(TimeSpan.FromMilliseconds(10)); + fc.OnInboundData(1, Start / 2); + Assert.Equal(Start * 2, fc.CurrentStreamWindow); + + fc.Reset(ConnWindow, Start); + + Assert.Equal(Start, fc.CurrentStreamWindow); + Assert.Equal(TimeSpan.Zero, fc.MinRtt); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2FrameDecoderBoundarySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2FrameDecoderBoundarySpec.cs index 0f547de69..445194a2e 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2FrameDecoderBoundarySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2FrameDecoderBoundarySpec.cs @@ -200,6 +200,44 @@ public void Http2FrameDecoder_should_ignore_all_when_multiple_unknown_frame_type Assert.Empty(frames); } + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-4.2")] + public void Http2FrameDecoder_should_throw_frame_size_error_when_frame_exceeds_configured_max() + { + // RFC 9113 §4.2: a frame whose length exceeds the locally-advertised SETTINGS_MAX_FRAME_SIZE + // is a FRAME_SIZE_ERROR. A DATA frame is used so the failure is the size check, not a + // frame-type-specific length rule. + var decoder = new FrameDecoder(16384); + const int overSize = 16385; + var frame = new byte[9 + overSize]; + frame[0] = (byte)(overSize >> 16); + frame[1] = (byte)(overSize >> 8); + frame[2] = (byte)(overSize & 0xFF); + frame[3] = 0x00; // DATA + frame[8] = 1; // stream 1 + + Assert.Throws(() => decoder.Decode(frame)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-4.2")] + public void Http2FrameDecoder_should_accept_frame_exactly_at_configured_max() + { + var decoder = new FrameDecoder(16384); + const int maxPayload = 16384; + var frame = new byte[9 + maxPayload]; + frame[0] = (byte)(maxPayload >> 16); + frame[1] = (byte)(maxPayload >> 8); + frame[2] = (byte)(maxPayload & 0xFF); + frame[3] = 0x00; // DATA + frame[8] = 1; // stream 1 + + var frames = decoder.Decode(frame); + + Assert.NotEmpty(frames); + Assert.IsType(frames[0]); + } + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-4.1")] public void Http2FrameDecoder_should_ignore_when_unknown_frame_type_has_large_payload() diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2PrefaceBuilderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2PrefaceBuilderSpec.cs index 698962c50..8ac78301c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2PrefaceBuilderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2PrefaceBuilderSpec.cs @@ -25,11 +25,13 @@ private static (SettingsParameter Key, uint Value) ReadSetting(ReadOnlySpan 65535"); + + var increment = BinaryPrimitives.ReadUInt32BigEndian(span[(length - 4)..]); + Assert.Equal((uint)(connectionWindow - DefaultWindow), increment); + owner.Dispose(); + } + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.9")] - public void PrefaceBuilder_should_include_window_update_when_initial_window_exceeds_65535() + public void PrefaceBuilder_should_include_window_update_when_connection_window_exceeds_65535() { - const int largeWindow = 64 * 1024 * 1024; - var (owner, length) = PrefaceBuilder.Build(largeWindow); + const int largeConnectionWindow = 64 * 1024 * 1024; + var (owner, length) = PrefaceBuilder.Build(DefaultWindow, largeConnectionWindow, 4096, 16 * 1024); var span = owner.Memory.Span[..length]; ParseSettings(span, out var hasWindowUpdate); - Assert.True(hasWindowUpdate, "Expected WINDOW_UPDATE frame for window > 65535"); + Assert.True(hasWindowUpdate, "Expected WINDOW_UPDATE frame for connection window > 65535"); owner.Dispose(); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.9")] - public void PrefaceBuilder_should_not_include_window_update_when_initial_window_is_65535() + public void PrefaceBuilder_should_not_include_window_update_when_connection_window_is_65535() { - var (owner, length) = PrefaceBuilder.Build(65535); + var (owner, length) = PrefaceBuilder.Build(DefaultWindow, DefaultWindow, 4096, 16 * 1024); var span = owner.Memory.Span[..length]; ParseSettings(span, out var hasWindowUpdate); - Assert.False(hasWindowUpdate, "No WINDOW_UPDATE expected when window == 65535"); + Assert.False(hasWindowUpdate, "No WINDOW_UPDATE expected when connection window == 65535"); owner.Dispose(); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackEncoderLargeHeaderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackEncoderLargeHeaderSpec.cs new file mode 100644 index 000000000..c6b3a85d2 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackEncoderLargeHeaderSpec.cs @@ -0,0 +1,63 @@ +using TurboHTTP.Protocol.Syntax.Http2.Hpack; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Hpack; + +public sealed class HpackEncoderLargeHeaderSpec +{ + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC7541-5.2")] + public void HpackEncoder_should_encode_value_larger_than_the_default_buffer() + { + // A large cookie/JWT exceeding the encoder's 4096-byte default rent must not overflow the buffer. + var encoder = new HpackEncoder(useHuffman: false); + var decoder = new HpackDecoder(); + + var longValue = new string('x', 8000); + var headers = new List<(string, string)> { ("x-long", longValue) }; + + var encoded = encoder.Encode(headers); + var decoded = decoder.Decode(encoded.Span); + + Assert.Single(decoded); + Assert.Equal("x-long", decoded[0].Name); + Assert.Equal(longValue, decoded[0].Value); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC7541-5.2")] + public void HpackEncoder_should_encode_large_value_with_huffman() + { + var encoder = new HpackEncoder(useHuffman: true); + var decoder = new HpackDecoder(); + + var longValue = new string('a', 10000); + var headers = new List<(string, string)> { ("x-long", longValue) }; + + var encoded = encoder.Encode(headers); + var decoded = decoder.Decode(encoded.Span); + + Assert.Single(decoded); + Assert.Equal(longValue, decoded[0].Value); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC7541-6")] + public void HpackEncoder_should_encode_many_headers_exceeding_the_default_buffer() + { + // Cumulative header list well past 4096 bytes across many fields. + var encoder = new HpackEncoder(useHuffman: false); + var decoder = new HpackDecoder(); + + var headers = new List<(string, string)>(); + for (var i = 0; i < 50; i++) + { + headers.Add(($"x-header-{i}", new string('v', 200))); + } + + var encoded = encoder.Encode(headers); + var decoded = decoder.Decode(encoded.Span); + + Assert.Equal(headers.Count, decoded.Count); + Assert.Equal(headers[^1].Item2, decoded[^1].Value); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs new file mode 100644 index 000000000..79ac60863 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.Time.Testing; +using TurboHTTP.Protocol.Syntax.Http2; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2; + +/// +/// Regression guard for HTTP/2 adaptive receive-window scaling (feature/h2-adaptive-window-scaling). +/// Unit specs for the pure and basic growth +/// exist already; this spec locks the integration invariants that protect against the flow-control +/// desync class of bug — most importantly that the window credit advertised to the peer (the emitted +/// WINDOW_UPDATE increments) stays exactly consistent with the window the receiver enforces, so a +/// conformant peer that fills the advertised window never trips a false FLOW_CONTROL_ERROR. +/// +public sealed class Http2AdaptiveWindowScalingRegressionSpec +{ + private const int Start = 64 * 1024; + private const int Cap = 16 * 1024 * 1024; + private const int ConnWindow = 64 * 1024 * 1024; + + private static FlowController NewScaling(FakeTimeProvider clock) => + new(ConnWindow, Start, new WindowScaler(Cap, 1.0), clock); + + private static void EstablishMinRtt(FlowController fc, FakeTimeProvider clock, int milliseconds) + { + fc.OnMeasurementPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(milliseconds)); + fc.OnMeasurementPingAck(); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Scaling_growth_increment_should_equal_consumed_bytes_plus_window_delta() + { + var clock = new FakeTimeProvider(); + var fc = NewScaling(clock); + EstablishMinRtt(fc, clock, 100); + + // First saturating round only seeds the sample timestamp (no growth yet). + fc.OnInboundData(1, Start / 2); + clock.Advance(TimeSpan.FromMilliseconds(10)); + + // Second round grows the window from Start to 2*Start. The emitted increment must replenish + // the just-consumed bytes (Start/2) AND grant the growth delta (2*Start - Start), so the peer + // is credited exactly the new window — no over- or under-crediting. + var result = fc.OnInboundData(1, Start / 2); + + Assert.True(result.Success); + Assert.Equal(Start * 2, fc.CurrentStreamWindow); + Assert.NotNull(result.StreamWindowUpdate); + Assert.Equal(Start / 2 + (Start * 2 - Start), result.StreamWindowUpdate!.Value.Increment); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Scaling_should_grow_monotonically_and_cap_at_max_window() + { + var clock = new FakeTimeProvider(); + var fc = NewScaling(clock); + EstablishMinRtt(fc, clock, 100); + + var previous = fc.CurrentStreamWindow; + + // Drive many saturating rounds; each round delivers exactly the current advertised window. + for (var round = 0; round < 40; round++) + { + var window = fc.CurrentStreamWindow; + fc.OnInboundData(1, window / 2); + clock.Advance(TimeSpan.FromMilliseconds(10)); + fc.OnInboundData(1, window - window / 2); + + var current = fc.CurrentStreamWindow; + Assert.True(current >= previous, "window must never shrink under scaling"); + Assert.True(current <= Cap, "window must never exceed the configured max"); + previous = current; + } + + Assert.Equal(Cap, fc.CurrentStreamWindow); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Filling_the_advertised_window_each_round_should_never_trigger_a_flow_control_violation() + { + // This is the core safety property: whatever window the receiver advertises (CurrentStreamWindow, + // replenished by the WINDOW_UPDATEs it emits), a peer is entitled to fill it. Doing so must never + // be classified as a stream or connection violation. A desync between advertised and enforced + // windows (the preface bug fixed alongside this) would surface here. + var clock = new FakeTimeProvider(); + var fc = NewScaling(clock); + EstablishMinRtt(fc, clock, 100); + + for (var round = 0; round < 60; round++) + { + var window = fc.CurrentStreamWindow; + var first = fc.OnInboundData(1, window / 2); + clock.Advance(TimeSpan.FromMilliseconds(10)); + var second = fc.OnInboundData(1, window - window / 2); + + Assert.True(first.Success, $"round {round}: first half violated flow control"); + Assert.False(first.IsStreamViolation || first.IsConnectionViolation); + Assert.True(second.Success, $"round {round}: filling the advertised window violated flow control"); + Assert.False(second.IsStreamViolation || second.IsConnectionViolation); + } + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Disabled_scaling_should_keep_a_fixed_window_under_identical_load() + { + // Contrast guard: without a scaler the window stays at Start no matter how saturated the link is. + var fc = new FlowController(ConnWindow, Start); + + for (var round = 0; round < 10; round++) + { + fc.OnInboundData(1, Start / 2); + fc.OnInboundData(1, Start / 2); + Assert.Equal(Start, fc.CurrentStreamWindow); + } + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptionsSpec.cs index c3115c880..4d89f713b 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptionsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptionsSpec.cs @@ -1,5 +1,4 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http2.Options; +using TurboHTTP.Client; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Options; @@ -7,25 +6,8 @@ public sealed class Http2ClientDecoderOptionsSpec { [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113")] - public void Default_should_hold_SharedHttpOptions_Default() + public void Default_client_options_should_project_sensible_decoder_values() { - Assert.Same(SharedHttpOptions.Default, Http2ClientDecoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { StreamingThreshold = -1 }; - var opts = Http2ClientDecoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_reject_invalid_MaxConcurrentStreams() - { - var opts = Http2ClientDecoderOptions.Default with { MaxConcurrentStreams = 0 }; - Assert.Throws(opts.Validate); + Assert.Equal(100, new TurboClientOptions().ToHttp2DecoderOptions().MaxConcurrentStreams); } } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptionsSpec.cs index 62b85caaa..6c74997cf 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptionsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptionsSpec.cs @@ -1,5 +1,4 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http2.Options; +using TurboHTTP.Client; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Options; @@ -7,25 +6,8 @@ public sealed class Http2ClientEncoderOptionsSpec { [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113")] - public void Default_should_hold_SharedHttpOptions_Default() + public void Default_client_options_should_project_sensible_encoder_values() { - Assert.Same(SharedHttpOptions.Default, Http2ClientEncoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { StreamingThreshold = -1 }; - var opts = Http2ClientEncoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_reject_invalid_MaxFrameSize() - { - var opts = Http2ClientEncoderOptions.Default with { MaxFrameSize = 100 }; - Assert.Throws(opts.Validate); + Assert.Equal(64 * 1024, new TurboClientOptions().ToHttp2EncoderOptions().MaxFrameSize); } } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ServerDecoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ServerDecoderOptionsSpec.cs deleted file mode 100644 index b929c8cc2..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ServerDecoderOptionsSpec.cs +++ /dev/null @@ -1,31 +0,0 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http2.Options; - -namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Options; - -public sealed class Http2ServerDecoderOptionsSpec -{ - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Default_should_hold_SharedHttpOptions_Default() - { - Assert.Same(SharedHttpOptions.Default, Http2ServerDecoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { StreamingThreshold = -1 }; - var opts = Http2ServerDecoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_reject_invalid_MaxConcurrentStreams() - { - var opts = Http2ServerDecoderOptions.Default with { MaxConcurrentStreams = 0 }; - Assert.Throws(opts.Validate); - } -} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ServerEncoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ServerEncoderOptionsSpec.cs deleted file mode 100644 index c4a820e6c..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Options/Http2ServerEncoderOptionsSpec.cs +++ /dev/null @@ -1,31 +0,0 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http2.Options; - -namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Options; - -public sealed class Http2ServerEncoderOptionsSpec -{ - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Default_should_hold_SharedHttpOptions_Default() - { - Assert.Same(SharedHttpOptions.Default, Http2ServerEncoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { StreamingThreshold = -1 }; - var opts = Http2ServerEncoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9113")] - public void Validate_should_reject_invalid_MaxFrameSize() - { - var opts = Http2ServerEncoderOptions.Default with { MaxFrameSize = 100 }; - Assert.Throws(opts.Validate); - } -} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/RttEstimatorSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/RttEstimatorSpec.cs new file mode 100644 index 000000000..5578c1c12 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/RttEstimatorSpec.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Time.Testing; +using TurboHTTP.Protocol.Syntax.Http2; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2; + +public sealed class RttEstimatorSpec +{ + [Fact(Timeout = 5000)] + public void RttEstimator_should_report_unknown_rtt_before_any_sample() + { + var clock = new FakeTimeProvider(); + var rtt = new RttEstimator(clock, TimeSpan.FromMilliseconds(100)); + + Assert.Equal(TimeSpan.Zero, rtt.MinRtt); + } + + [Fact(Timeout = 5000)] + public void RttEstimator_should_measure_rtt_from_ping_to_ack() + { + var clock = new FakeTimeProvider(); + var rtt = new RttEstimator(clock, TimeSpan.FromMilliseconds(100)); + + rtt.OnPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(40)); + rtt.OnPingAck(); + + Assert.Equal(TimeSpan.FromMilliseconds(40), rtt.MinRtt); + } + + [Fact(Timeout = 5000)] + public void RttEstimator_should_keep_minimum_across_samples() + { + var clock = new FakeTimeProvider(); + var rtt = new RttEstimator(clock, TimeSpan.FromMilliseconds(1)); + + rtt.OnPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(40)); + rtt.OnPingAck(); + + rtt.OnPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(20)); + rtt.OnPingAck(); + + rtt.OnPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(80)); + rtt.OnPingAck(); + + Assert.Equal(TimeSpan.FromMilliseconds(20), rtt.MinRtt); + } + + [Fact(Timeout = 5000)] + public void RttEstimator_should_not_send_ping_before_interval_elapses() + { + var clock = new FakeTimeProvider(); + var rtt = new RttEstimator(clock, TimeSpan.FromMilliseconds(100)); + + Assert.True(rtt.ShouldSendPing()); + rtt.OnPingSent(); + rtt.OnPingAck(); + + clock.Advance(TimeSpan.FromMilliseconds(50)); + Assert.False(rtt.ShouldSendPing()); + + clock.Advance(TimeSpan.FromMilliseconds(60)); + Assert.True(rtt.ShouldSendPing()); + } + + [Fact(Timeout = 5000)] + public void RttEstimator_should_not_send_ping_while_awaiting_ack() + { + var clock = new FakeTimeProvider(); + var rtt = new RttEstimator(clock, TimeSpan.FromMilliseconds(1)); + + rtt.OnPingSent(); + clock.Advance(TimeSpan.FromMilliseconds(10)); + + Assert.False(rtt.ShouldSendPing()); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerConnectSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerConnectSpec.cs index 3e2612026..a78538b1b 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerConnectSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerConnectSpec.cs @@ -1,13 +1,23 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Decoder; public sealed class Http2ServerConnectSpec { + private static Http2ServerDecoderOptions DefaultDecoderOptions() => new() + { + HeaderTableSize = 16 * 1024, + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + private readonly HpackEncoder _encoder = new(useHuffman: false); - private readonly Http2ServerDecoder _decoder = new(); + private readonly Http2ServerDecoder _decoder = new(DefaultDecoderOptions()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-8.5")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerDecoderSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerDecoderSecuritySpec.cs index dc51d6bb9..4b8748ed9 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerDecoderSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerDecoderSecuritySpec.cs @@ -1,13 +1,23 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Decoder; public sealed class Http2ServerDecoderSecuritySpec { + private static Http2ServerDecoderOptions DefaultDecoderOptions() => new() + { + HeaderTableSize = 16 * 1024, + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + private readonly HpackEncoder _encoder = new(useHuffman: false); - private readonly Http2ServerDecoder _decoder = new(); + private readonly Http2ServerDecoder _decoder = new(DefaultDecoderOptions()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-8.3")] @@ -257,7 +267,7 @@ public void DecodeHeaders_CONNECT_without_authority_should_reject() public void DecodeHeaders_should_reject_single_header_exceeding_max_size() { var maxHeaderSize = 64; - var decoder = new Http2ServerDecoder(maxHeaderSize: maxHeaderSize); + var decoder = new Http2ServerDecoder(DefaultDecoderOptions() with { MaxHeaderBytes = maxHeaderSize }); var largeValue = new string('x', 100); var headers = new List @@ -284,7 +294,7 @@ public void DecodeHeaders_should_reject_single_header_exceeding_max_size() public void DecodeHeaders_should_reject_total_headers_exceeding_max_total_size() { var maxTotalHeaderSize = 128; - var decoder = new Http2ServerDecoder(maxTotalHeaderSize: maxTotalHeaderSize); + var decoder = new Http2ServerDecoder(DefaultDecoderOptions() with { MaxFieldSectionSize = maxTotalHeaderSize }); var headers = new List { @@ -301,10 +311,12 @@ public void DecodeHeaders_should_reject_total_headers_exceeding_max_total_size() var encoded = EncodeHeaders(headers); var state = BuildStreamState(encoded); - var ex = Assert.Throws(() => + // Total header-list size is enforced at the HPACK layer (RFC 9113 §6.5.2 / MAX_HEADER_LIST_SIZE), + // which rejects during decode before the full list is materialized — a COMPRESSION_ERROR. + var ex = Assert.Throws(() => decoder.DecodeHeadersToFeature(streamId: 1, endStream: true, state)); - Assert.Contains("exceeds MaxTotalHeaderSize", ex.Message); + Assert.Contains("MAX_HEADER_LIST_SIZE", ex.Message); Assert.Contains("128", ex.Message); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerFieldValidationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerFieldValidationSpec.cs index 67d00ef5c..f62e283a4 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerFieldValidationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerFieldValidationSpec.cs @@ -1,13 +1,23 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Decoder; public sealed class Http2ServerFieldValidationSpec { + private static Http2ServerDecoderOptions DefaultDecoderOptions() => new() + { + HeaderTableSize = 16 * 1024, + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + private readonly HpackEncoder _encoder = new(useHuffman: false); - private readonly Http2ServerDecoder _decoder = new(); + private readonly Http2ServerDecoder _decoder = new(DefaultDecoderOptions()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-8.2")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerPseudoHeaderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerPseudoHeaderSpec.cs index 2b2d280da..254976690 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerPseudoHeaderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerPseudoHeaderSpec.cs @@ -1,13 +1,23 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Decoder; public sealed class Http2ServerPseudoHeaderSpec { + private static Http2ServerDecoderOptions DefaultDecoderOptions() => new() + { + HeaderTableSize = 16 * 1024, + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + private readonly HpackEncoder _encoder = new(useHuffman: false); - private readonly Http2ServerDecoder _decoder = new(); + private readonly Http2ServerDecoder _decoder = new(DefaultDecoderOptions()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-8.3")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerRequestDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerRequestDecoderSpec.cs index e05fcd78a..c02dac63f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerRequestDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerRequestDecoderSpec.cs @@ -1,13 +1,23 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Decoder; public sealed class Http2ServerRequestDecoderSpec { + private static Http2ServerDecoderOptions DefaultDecoderOptions() => new() + { + HeaderTableSize = 16 * 1024, + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + private readonly HpackEncoder _encoder = new(useHuffman: false); - private readonly Http2ServerDecoder _decoder = new(); + private readonly Http2ServerDecoder _decoder = new(DefaultDecoderOptions()); [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-8.3")] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Security/Http2ServerSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Security/Http2ServerSecuritySpec.cs index ffa6944c8..62f67f021 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Security/Http2ServerSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Security/Http2ServerSecuritySpec.cs @@ -1,5 +1,6 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Decoder.Security; @@ -24,13 +25,22 @@ public sealed class Http2ServerSecuritySpec { private readonly HpackEncoder _encoder = new(useHuffman: false); + private static Http2ServerDecoderOptions DefaultDecoderOptions() => new() + { + HeaderTableSize = 16 * 1024, + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-10.5.1")] public void Hpack_bomb_should_be_rejected_by_header_size_limit() { // Test: single header with size exceeding maxHeaderSize (256 bytes) const int maxHeaderSize = 256; - var decoder = new Http2ServerDecoder(maxHeaderSize: maxHeaderSize); + var decoder = new Http2ServerDecoder(DefaultDecoderOptions() with { MaxHeaderBytes = maxHeaderSize }); // Create a header with a 300-byte value to exceed the limit var largeValue = new string('x', 300); @@ -60,7 +70,7 @@ public void Many_small_headers_exceeding_total_size_should_be_rejected() { // Test: many small headers that individually pass but collectively exceed maxTotalHeaderSize (256 bytes) const int maxTotalHeaderSize = 256; - var decoder = new Http2ServerDecoder(maxTotalHeaderSize: maxTotalHeaderSize); + var decoder = new Http2ServerDecoder(DefaultDecoderOptions() with { MaxFieldSectionSize = maxTotalHeaderSize }); var headers = new List { @@ -84,10 +94,12 @@ public void Many_small_headers_exceeding_total_size_should_be_rejected() var encoded = EncodeHeaders(headers); var state = BuildStreamState(encoded); - var ex = Assert.Throws(() => + // Total header-list size is enforced at the HPACK layer (RFC 9113 §6.5.2 / MAX_HEADER_LIST_SIZE), + // rejecting during decode before the full list is materialized — a COMPRESSION_ERROR. + var ex = Assert.Throws(() => decoder.DecodeHeadersToFeature(streamId: 1, endStream: true, state)); - Assert.Contains("exceeds MaxTotalHeaderSize", ex.Message); + Assert.Contains("MAX_HEADER_LIST_SIZE", ex.Message); Assert.Contains("256", ex.Message); Assert.Contains("RFC 9113", ex.Message); } @@ -97,7 +109,7 @@ public void Many_small_headers_exceeding_total_size_should_be_rejected() public void Uppercase_header_name_should_be_rejected() { // Test: header name with uppercase character (RFC 9113 §8.2.1 requires lowercase) - var decoder = new Http2ServerDecoder(); + var decoder = new Http2ServerDecoder(DefaultDecoderOptions()); var headers = new List { @@ -124,7 +136,7 @@ public void Uppercase_header_name_should_be_rejected() public void Header_value_with_null_byte_should_be_rejected() { // Test: header value containing NUL byte (0x00) — forbidden per RFC 9113 §10.3 - var decoder = new Http2ServerDecoder(); + var decoder = new Http2ServerDecoder(DefaultDecoderOptions()); var headers = new List { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerEncoderFragmentationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerEncoderFragmentationSpec.cs index e78fe7538..cf1e16b38 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerEncoderFragmentationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerEncoderFragmentationSpec.cs @@ -1,5 +1,6 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Tests.Shared; using Microsoft.AspNetCore.Http.Features; @@ -8,7 +9,16 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Encoder; public sealed class Http2ServerEncoderFragmentationSpec { - private readonly Http2ServerEncoder _encoder = new(); + private static Http2ServerEncoderOptions DefaultEncoderOptions() => new() + { + MaxFrameSize = 16 * 1024, + HeaderTableSize = 4096, + WriteDateHeader = false, + MaxHeaderBytes = 32 * 1024, + UseHuffman = true + }; + + private readonly Http2ServerEncoder _encoder = new(DefaultEncoderOptions()); private readonly HpackDecoder _decoder = new(); [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseBufferSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseBufferSpec.cs index 05c746144..71525a300 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseBufferSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseBufferSpec.cs @@ -2,6 +2,7 @@ using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Server; using TurboHTTP.Tests.Shared; @@ -10,6 +11,15 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Encoder; public sealed class Http2ServerResponseBufferSpec { + private static Http2ServerEncoderOptions DefaultEncoderOptions() => new() + { + MaxFrameSize = 16 * 1024, + HeaderTableSize = 4096, + WriteDateHeader = false, + MaxHeaderBytes = 32 * 1024, + UseHuffman = true + }; + private static byte[] BuildHeadersFrame(int streamId, ReadOnlyMemory headerBlock, bool endStream = false, bool endHeaders = true) { @@ -89,7 +99,7 @@ private static void DecodeFramesAsStream(Http2ServerStateMachine sm, byte[] fram var buffer = TransportBuffer.Rent(frameData.Length); frameData.CopyTo(buffer.FullMemory.Span); buffer.Length = frameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); } private static List ExtractFrames(List outbound, int startIndex = 0) @@ -114,7 +124,7 @@ private static List ExtractFrames(List outbound, public void OnResponse_with_no_body_should_send_headers_with_endstream() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send HEADERS frame for stream 1 var headerBlock = EncodeHeaders("GET", "/api/status", "example.com"); @@ -146,7 +156,7 @@ public void OnResponse_with_no_body_should_send_headers_with_endstream() public void OnResponse_with_body_should_schedule_drain_timer_and_not_set_endstream() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send HEADERS frame for stream 1 var headerBlock = EncodeHeaders("GET", "/api/data", "example.com"); @@ -177,7 +187,7 @@ public void OnResponse_with_body_should_schedule_drain_timer_and_not_set_endstre public void WindowUpdate_should_drain_outbound_buffer() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); var headerBlock = EncodeHeaders("GET", "/api/data", "example.com"); var headersFrameData = BuildHeadersFrame(streamId: 1, headerBlock, endStream: true, endHeaders: true); @@ -197,7 +207,7 @@ public void WindowUpdate_should_drain_outbound_buffer() [Trait("RFC", "RFC9113-6.2")] public void ServerResponseEncoder_EncodeHeaders_with_body_flag_should_not_set_endstream() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); var ctx = ServerTestContext.CreateResponse(); @@ -212,7 +222,7 @@ public void ServerResponseEncoder_EncodeHeaders_with_body_flag_should_not_set_en [Trait("RFC", "RFC9113-6.2")] public void ServerResponseEncoder_EncodeHeaders_without_body_flag_should_set_endstream() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); var ctx = ServerTestContext.CreateResponse(204); @@ -227,7 +237,7 @@ public void ServerResponseEncoder_EncodeHeaders_without_body_flag_should_set_end [Trait("RFC", "RFC9113-6.2")] public void ServerResponseEncoder_ApplyClientSettings_should_update_max_frame_size() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); var initialMaxFrameSize = encoder.MaxFrameSize; encoder.ApplyClientSettings([(SettingsParameter.MaxFrameSize, 32768u)]); @@ -240,7 +250,7 @@ public void ServerResponseEncoder_ApplyClientSettings_should_update_max_frame_si [Trait("RFC", "RFC9113-6.2")] public void ServerResponseEncoder_ApplyClientSettings_should_ignore_initial_window_size() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); // This should not throw and should be ignored by encoder encoder.ApplyClientSettings([(SettingsParameter.InitialWindowSize, 32768u)]); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseEncoderSpec.cs index 472127f3c..26cfe2e0a 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseEncoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseEncoderSpec.cs @@ -1,5 +1,6 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Tests.Shared; using Microsoft.AspNetCore.Http.Features; @@ -8,7 +9,16 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Encoder; public sealed class Http2ServerResponseEncoderSpec { - private readonly Http2ServerEncoder _encoder = new(); + private static Http2ServerEncoderOptions DefaultEncoderOptions() => new() + { + MaxFrameSize = 16 * 1024, + HeaderTableSize = 4096, + WriteDateHeader = false, + MaxHeaderBytes = 32 * 1024, + UseHuffman = true + }; + + private readonly Http2ServerEncoder _encoder = new(DefaultEncoderOptions()); private readonly HpackDecoder _decoder = new(); [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseFrameSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseFrameSpec.cs index 10afff654..e8847757c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseFrameSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Encoder/Http2ServerResponseFrameSpec.cs @@ -1,5 +1,6 @@ using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Tests.Shared; using Microsoft.AspNetCore.Http.Features; @@ -8,7 +9,16 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Encoder; public sealed class Http2ServerResponseFrameSpec { - private readonly Http2ServerEncoder _encoder = new(); + private static Http2ServerEncoderOptions DefaultEncoderOptions() => new() + { + MaxFrameSize = 16 * 1024, + HeaderTableSize = 4096, + WriteDateHeader = false, + MaxHeaderBytes = 32 * 1024, + UseHuffman = true + }; + + private readonly Http2ServerEncoder _encoder = new(DefaultEncoderOptions()); private readonly HpackDecoder _decoder = new(); [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ResponseDataRateSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ResponseDataRateSpec.cs new file mode 100644 index 000000000..f99055646 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ResponseDataRateSpec.cs @@ -0,0 +1,121 @@ +using TurboHTTP.Protocol; +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server; + +public sealed class Http2ResponseDataRateSpec +{ + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Response_data_rate_monitor_should_be_initialized() + { + var options = new TurboServerOptions + { + Http2 = { MinResponseDataRate = 1_000_000, MinResponseDataRateGracePeriod = TimeSpan.FromSeconds(5) } + }; + + var rateOptions = options.ToHttp2Options().ToRateMonitor(); + + Assert.Equal(1_000_000, rateOptions.MinResponseDataRate); + Assert.Equal(TimeSpan.FromSeconds(5), rateOptions.MinResponseDataRateGracePeriod); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Response_data_rate_monitor_should_track_violations() + { + var options = new TurboServerOptions + { + Http2 = { MinResponseDataRate = 1_000_000, MinResponseDataRateGracePeriod = TimeSpan.FromMilliseconds(100) } + }; + + var rateOptions = options.ToHttp2Options().ToRateMonitor(); + var responseMonitor = new DataRateMonitor(rateOptions.MinResponseDataRate, rateOptions.MinResponseDataRateGracePeriod); + + var now = Environment.TickCount64; + + // Observe a small amount of data (100 bytes) + responseMonitor.Observe(streamId: 1, bytes: 100, now: now); + + Assert.Equal(1, responseMonitor.Count); + + // At initial check, should be in grace period (not a violation yet) + var violations = new List(); + responseMonitor.Check(now + 550, violations); + + // No violation yet (grace period not expired) + Assert.Empty(violations); + + // Wait past grace period (100ms) and check again + violations.Clear(); + responseMonitor.Check(now + 1100, violations); + + // Should have violation now (grace period expired, rate still below minimum) + Assert.Single(violations); + Assert.Equal(1L, violations[0]); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Response_data_rate_can_be_disabled() + { + var options = new TurboServerOptions + { + Http2 = { MinResponseDataRate = 0 } + }; + + var rateOptions = options.ToHttp2Options().ToRateMonitor(); + var responseMonitor = new DataRateMonitor(rateOptions.MinResponseDataRate, rateOptions.MinResponseDataRateGracePeriod); + + var now = Environment.TickCount64; + + // Observe data + responseMonitor.Observe(streamId: 1, bytes: 1, now: now); + + // When disabled (rate = 0), Observe should not track anything + Assert.Equal(0, responseMonitor.Count); + + // Check should do nothing when disabled + var violations = new List(); + responseMonitor.Check(now + 10000, violations); + + Assert.Empty(violations); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Response_data_rate_recovery_should_exit_grace_period() + { + var options = new TurboServerOptions + { + Http2 = { MinResponseDataRate = 1_000_000 } + }; + + var rateOptions = options.ToHttp2Options().ToRateMonitor(); + var responseMonitor = new DataRateMonitor(rateOptions.MinResponseDataRate, rateOptions.MinResponseDataRateGracePeriod); + + var now = Environment.TickCount64; + + // Observe a small amount (violates rate) + responseMonitor.Observe(streamId: 1, bytes: 100, now: now); + + var violations = new List(); + + // First check: enters grace period + responseMonitor.Check(now + 550, violations); + Assert.Empty(violations); + + // Second check: still in grace period (not expired yet) + violations.Clear(); + responseMonitor.Check(now + 650, violations); + Assert.Empty(violations); + + // Now observe a large burst of data (high rate) + responseMonitor.Observe(streamId: 1, bytes: 10_000_000, now: now + 700); + + // Check again: rate should be high now, exiting grace period + violations.Clear(); + responseMonitor.Check(now + 1200, violations); + Assert.Empty(violations); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ServerOptionsResolutionSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ServerOptionsResolutionSpec.cs new file mode 100644 index 000000000..605bed728 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ServerOptionsResolutionSpec.cs @@ -0,0 +1,22 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server; + +public sealed class Http2ServerOptionsResolutionSpec +{ + [Fact(Timeout = 5000)] + public void Null_keepalive_override_should_resolve_to_limits() + { + var o = new TurboServerOptions + { + Limits = + { + KeepAliveTimeout = TimeSpan.FromSeconds(42) + } + }; + + var eff = o.ToHttp2Options(); + + Assert.Equal(TimeSpan.FromSeconds(42), eff.Limits.KeepAliveTimeout); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ServerTrailerEncodingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ServerTrailerEncodingSpec.cs index c52e73537..3e42ca9df 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ServerTrailerEncodingSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2ServerTrailerEncodingSpec.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http.Features; using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Server.Context; using TurboHTTP.Server.Context.Features; @@ -9,6 +10,15 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server; public sealed class Http2ServerTrailerEncodingSpec { + private static Http2ServerEncoderOptions DefaultEncoderOptions() => new() + { + MaxFrameSize = 16 * 1024, + HeaderTableSize = 4096, + WriteDateHeader = false, + MaxHeaderBytes = 32 * 1024, + UseHuffman = true + }; + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-8.1")] public void TrailerFeature_should_store_and_retrieve_trailer_headers() @@ -70,7 +80,7 @@ public void ResponseTrailersFeature_should_store_and_expose_trailers() [Trait("RFC", "RFC9113-8.1")] public void Encoder_should_produce_trailing_HEADERS_frame_with_END_STREAM() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); var trailers = new TurboResponseHeaderDictionary { { "grpc-status", "0" }, @@ -92,7 +102,7 @@ public void Encoder_should_produce_trailing_HEADERS_frame_with_END_STREAM() [Trait("RFC", "RFC9110-6.5.1")] public void Encoder_should_filter_prohibited_trailer_fields() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); var decoder = new HpackDecoder(); var trailers = new TurboResponseHeaderDictionary diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2StreamStateBackpressureSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2StreamStateBackpressureSpec.cs new file mode 100644 index 000000000..93e7a8572 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Http2StreamStateBackpressureSpec.cs @@ -0,0 +1,62 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http2; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server; + +public sealed class Http2StreamStateBackpressureSpec +{ + private static StreamBodyChunk Chunk(int len) + { + var owner = MemoryPool.Shared.Rent(len); + return new StreamBodyChunk(owner, len); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.2")] + public void Enqueue_should_track_pending_bytes() + { + var state = new StreamState(); + state.MarkBodyDrainActive(); + + state.EnqueueBodyChunk(Chunk(60)); + Assert.Equal(60, state.PendingOutboundBytes); + Assert.True(state.HasPendingOutbound); + + state.EnqueueBodyChunk(Chunk(40)); + Assert.Equal(100, state.PendingOutboundBytes); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.2")] + public void Dequeue_should_reduce_pending_bytes() + { + var state = new StreamState(); + state.MarkBodyDrainActive(); + + state.EnqueueBodyChunk(Chunk(60)); + state.EnqueueBodyChunk(Chunk(40)); + + state.TryDequeueBodyChunk(out var c1); + c1!.Owner.Dispose(); + Assert.Equal(40, state.PendingOutboundBytes); + + state.TryDequeueBodyChunk(out var c2); + c2!.Owner.Dispose(); + Assert.Equal(0, state.PendingOutboundBytes); + Assert.False(state.HasPendingOutbound); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.2")] + public void MarkBodyDrainComplete_should_signal_drain_finished() + { + var state = new StreamState(); + state.MarkBodyDrainActive(); + Assert.True(state.HasBodyDrain); + Assert.False(state.IsBodyDrainComplete); + + state.MarkBodyDrainComplete(); + Assert.True(state.IsBodyDrainComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ConnectionErrorTeardownSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ConnectionErrorTeardownSpec.cs new file mode 100644 index 000000000..d80645f03 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ConnectionErrorTeardownSpec.cs @@ -0,0 +1,79 @@ +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.SessionManager; + +public sealed class Http2ConnectionErrorTeardownSpec +{ + private static Http2ServerSessionManager CreateSessionManager(FakeServerOps ops) + { + var options = new TurboServerOptions(); + return new Http2ServerSessionManager(options.ToHttp2Options(), ops); + } + + private static TransportBuffer WrapFrame(byte[] frame) + { + var buffer = TransportBuffer.Rent(frame.Length); + frame.CopyTo(buffer.FullMemory.Span); + buffer.Length = frame.Length; + return buffer; + } + + private static TransportData? FindFrame(FakeServerOps ops, FrameType type) => + ops.Outbound.OfType().FirstOrDefault(td => td.Buffer.Span[3] == (byte)type); + + private static int ReadGoAwayErrorCode(TransportData goAway) + { + var s = goAway.Buffer.Span; + return (s[13] << 24) | (s[14] << 16) | (s[15] << 8) | s[16]; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.4.1")] + public void Connection_protocol_error_should_emit_goaway_and_request_completion() + { + var ops = new FakeServerOps(); + var sm = CreateSessionManager(ops); + sm.PreStart(); + ops.Outbound.Clear(); + + // Bare CONTINUATION with no preceding HEADERS is a connection error (RFC 9113 §6.10). + var frame = new byte[9]; + frame[3] = (byte)FrameType.Continuation; + frame[8] = 1; + sm.DecodeClientData(WrapFrame(frame)); + + Assert.True(sm.ShouldComplete); + var goAway = FindFrame(ops, FrameType.GoAway); + Assert.NotNull(goAway); + Assert.Equal((int)Http2ErrorCode.ProtocolError, ReadGoAwayErrorCode(goAway)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-4.3")] + public void Hpack_decode_error_should_emit_goaway_with_compression_error() + { + var ops = new FakeServerOps(); + var sm = CreateSessionManager(ops); + sm.PreStart(); + ops.Outbound.Clear(); + + // HEADERS with END_HEADERS whose HPACK payload is an indexed field referencing index 0 (invalid). + var payload = new byte[] { 0x80 }; + var frame = new byte[9 + payload.Length]; + frame[2] = (byte)payload.Length; + frame[3] = (byte)FrameType.Headers; + frame[4] = 0x04; // END_HEADERS + frame[8] = 1; + payload.CopyTo(frame.AsSpan(9)); + sm.DecodeClientData(WrapFrame(frame)); + + Assert.True(sm.ShouldComplete); + var goAway = FindFrame(ops, FrameType.GoAway); + Assert.NotNull(goAway); + Assert.Equal((int)Http2ErrorCode.CompressionError, ReadGoAwayErrorCode(goAway)); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ContinuationStateSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ContinuationStateSpec.cs index 115ad0a05..bafbd1029 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ContinuationStateSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ContinuationStateSpec.cs @@ -1,7 +1,6 @@ using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; -using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Tests.Shared; using Microsoft.AspNetCore.Http.Features; @@ -102,10 +101,9 @@ private static TransportBuffer WrapFrame(byte[] frame) public void Headers_without_EndHeaders_then_Continuation_should_emit_request() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); // Clear initial SETTINGS frame @@ -145,13 +143,12 @@ public void Headers_without_EndHeaders_then_Continuation_should_emit_request() [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.10")] - public void Continuation_on_wrong_stream_should_throw_protocol_error() + public void Continuation_on_wrong_stream_should_emit_goaway_protocol_error() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); // Clear initial SETTINGS frame @@ -167,19 +164,31 @@ public void Continuation_on_wrong_stream_should_throw_protocol_error() endHeaders: false); sm.DecodeClientData(WrapFrame(headersFrame)); - // Send CONTINUATION on stream 3 (wrong stream) - // This should throw a protocol exception at the frame decoder level + // Send CONTINUATION on stream 3 (wrong stream). RFC 9113 §6.10 requires CONTINUATION on the + // same stream; the session manager treats the violation as a connection error, emitting + // GOAWAY(PROTOCOL_ERROR) and requesting completion rather than propagating the exception. var continuationFrame = BuildContinuationFrame( streamId: 3, headerBlock[splitPoint..], endHeaders: true); - // RFC 9113 §6.10 requires a CONTINUATION on the same stream - // The FrameDecoder catches this before SessionManager processing - var ex = Assert.Throws(() => { sm.DecodeClientData(WrapFrame(continuationFrame)); }); + sm.DecodeClientData(WrapFrame(continuationFrame)); + + Assert.True(sm.ShouldComplete); - Assert.Contains("RFC 9113 §6.10", ex.Message); - Assert.Contains("stream", ex.Message); + TransportData? goAway = null; + foreach (var item in ops.Outbound) + { + if (item is TransportData td && td.Buffer.Span[3] == (byte)FrameType.GoAway) + { + goAway = td; + } + } + + Assert.NotNull(goAway); + var s = goAway.Buffer.Span; + var code = (s[13] << 24) | (s[14] << 16) | (s[15] << 8) | s[16]; + Assert.Equal((int)Http2ErrorCode.ProtocolError, code); } [Fact(Timeout = 5000)] @@ -187,10 +196,9 @@ public void Continuation_on_wrong_stream_should_throw_protocol_error() public void Headers_with_EndHeaders_true_should_emit_request_immediately() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); @@ -220,10 +228,9 @@ public void Headers_with_EndHeaders_true_should_emit_request_immediately() public void Headers_without_EndHeaders_should_schedule_headers_timeout() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.ScheduledTimers.Clear(); @@ -255,10 +262,9 @@ public void Headers_without_EndHeaders_should_schedule_headers_timeout() public void Continuation_with_EndHeaders_should_cancel_headers_timeout() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.ScheduledTimers.Clear(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2DataRateViolationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2DataRateViolationSpec.cs new file mode 100644 index 000000000..704c1d254 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2DataRateViolationSpec.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Time.Testing; +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.SessionManager; + +public sealed class Http2DataRateViolationSpec +{ + private static byte[] BuildHeadersFrame(int streamId, bool endStream) + { + var encoder = new HpackEncoder(useHuffman: false); + var headers = new List + { + new(":method", "POST"), + new(":path", "/"), + new(":scheme", "https"), + new(":authority", "localhost"), + }; + + var buf = new byte[4096]; + var span = buf.AsSpan(); + var written = encoder.Encode(headers, ref span, useHuffman: false); + + const int h = 9; + var frame = new byte[h + written]; + frame[0] = (byte)(written >> 16); + frame[1] = (byte)(written >> 8); + frame[2] = (byte)written; + frame[3] = (byte)FrameType.Headers; + byte flags = 0x04; // END_HEADERS + if (endStream) + { + flags |= 0x01; + } + + frame[4] = flags; + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + buf.AsSpan(0, written).CopyTo(frame.AsSpan(h)); + return frame; + } + + private static byte[] BuildDataFrame(int streamId, int dataLength) + { + const int h = 9; + var frame = new byte[h + dataLength]; + frame[0] = (byte)(dataLength >> 16); + frame[1] = (byte)(dataLength >> 8); + frame[2] = (byte)dataLength; + frame[3] = (byte)FrameType.Data; + frame[4] = 0; // no END_STREAM — body keeps flowing + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + return frame; + } + + private static TransportBuffer WrapFrame(byte[] frame) + { + var buffer = TransportBuffer.Rent(frame.Length); + frame.CopyTo(buffer.FullMemory.Span); + buffer.Length = frame.Length; + return buffer; + } + + private static bool HasRstStream(FakeServerOps ops) + { + foreach (var outbound in ops.Outbound) + { + if (outbound is TransportData { Buffer.Length: >= 9 } td + && (FrameType)td.Buffer.FullMemory.Span[3] == FrameType.RstStream) + { + return true; + } + } + + return false; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void Slow_request_body_should_emit_rst_stream_after_grace_with_injected_clock() + { + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var options = new TurboServerOptions + { + Http2 = { MinRequestBodyDataRate = 1000, MinRequestBodyDataRateGracePeriod = TimeSpan.FromSeconds(1) } + }.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops, clock); + + sm.PreStart(); + ops.Outbound.Clear(); + + // Open stream 1 (POST, body to follow) and deliver a tiny DATA frame, then stall. + sm.DecodeClientData(WrapFrame(BuildHeadersFrame(1, endStream: false))); + sm.DecodeClientData(WrapFrame(BuildDataFrame(1, dataLength: 5))); + + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.CheckDataRates(); + Assert.False(HasRstStream(ops), "Should be within grace period at first check"); + + // 5 bytes over 1700ms = ~2.9 bytes/sec << 1000; grace (1s) expired → RST_STREAM. + clock.Advance(TimeSpan.FromMilliseconds(1100)); + sm.CheckDataRates(); + Assert.True(HasRstStream(ops), "Expected RST_STREAM after request-body rate violation"); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2FlowControlEnforcementSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2FlowControlEnforcementSpec.cs index 8dd84106e..bf39788e9 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2FlowControlEnforcementSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2FlowControlEnforcementSpec.cs @@ -2,7 +2,6 @@ using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; -using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Server; using TurboHTTP.Tests.Shared; @@ -94,10 +93,9 @@ private static TransportBuffer WrapFrame(byte[] frame) public void WindowUpdate_on_stream_0_should_not_crash() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); // Clear initial SETTINGS frame @@ -116,10 +114,9 @@ public void WindowUpdate_on_stream_0_should_not_crash() public void Data_on_closed_stream_should_emit_RstStream() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); // Clear initial SETTINGS frame @@ -177,10 +174,9 @@ public void Data_on_closed_stream_should_emit_RstStream() public void Empty_data_with_EndStream_should_complete_request_body() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); // Clear initial SETTINGS frame diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2HeadersTimerLeakSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2HeadersTimerLeakSpec.cs new file mode 100644 index 000000000..bd34af000 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2HeadersTimerLeakSpec.cs @@ -0,0 +1,197 @@ +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.SessionManager; + +/// +/// Regression tests for the HeadersTimeout timer leak in Http2ServerSessionManager.CloseStream(). +/// Previously, CloseStream() cancelled BodyConsumptionTimerKey but omitted HeadersTimeoutTimerKey, +/// leaving the headers-timeout:<id> timer armed after the stream was closed. +/// +public sealed class Http2HeadersTimerLeakSpec +{ + private static ReadOnlyMemory EncodeMinimalHeaders() + { + var encoder = new HpackEncoder(useHuffman: false); + var headers = new List + { + new(":method", "GET"), + new(":path", "/"), + new(":scheme", "https"), + new(":authority", "localhost"), + }; + var buffer = new byte[4096]; + var span = buffer.AsSpan(); + var written = encoder.Encode(headers, ref span, useHuffman: false); + return new Memory(buffer, 0, written); + } + + private static byte[] BuildHeadersFrame( + int streamId, + ReadOnlyMemory headerBlock, + bool endStream = false, + bool endHeaders = true) + { + const int h = 9; + var frame = new byte[h + headerBlock.Length]; + var length = headerBlock.Length; + frame[0] = (byte)(length >> 16); + frame[1] = (byte)(length >> 8); + frame[2] = (byte)length; + frame[3] = (byte)FrameType.Headers; + byte flags = 0; + if (endStream) + { + flags |= 0x01; + } + + if (endHeaders) + { + flags |= 0x04; + } + + frame[4] = flags; + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + headerBlock.Span.CopyTo(frame.AsSpan(h)); + return frame; + } + + private static TransportBuffer WrapFrame(byte[] frame) + { + var buffer = TransportBuffer.Rent(frame.Length); + frame.CopyTo(buffer.FullMemory.Span); + buffer.Length = frame.Length; + return buffer; + } + + private static Http2ServerSessionManager CreateSm(FakeServerOps ops) + { + var options = new TurboServerOptions().ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); + sm.PreStart(); + ops.Outbound.Clear(); + ops.ScheduledTimers.Clear(); + ops.CancelledTimers.Clear(); + return sm; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.4")] + public void EmitRstStream_should_cancel_headers_timeout_timer() + { + // Regression: CloseStream() previously did NOT cancel HeadersTimeoutTimerKey. + // When the server emits RST_STREAM (e.g. to refuse or abort a stream), the + // headers-timeout: timer was left armed, leaking a timer handle. + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + + var headerBlock = EncodeMinimalHeaders(); + + // Open stream 1 fully (END_HEADERS so no CONTINUATION expected) + var headersFrame = BuildHeadersFrame(streamId: 1, headerBlock, endStream: false, endHeaders: true); + sm.DecodeClientData(WrapFrame(headersFrame)); + Assert.Equal(1, sm.ActiveStreamCount); + + ops.CancelledTimers.Clear(); + + // Server sends RST_STREAM — calls CloseStream internally + sm.EmitRstStream(streamId: 1, Http2ErrorCode.Cancel); + + // HeadersTimeoutTimerKey must be in the cancelled list (even if it was never scheduled, + // a defensive cancel is the correct behaviour) + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("headers-timeout:1")); + Assert.Equal(0, sm.ActiveStreamCount); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.4")] + public void EmitRstStream_should_cancel_both_body_and_headers_timers() + { + // Combined guard: both BodyConsumptionTimerKey and HeadersTimeoutTimerKey must be + // cancelled — protects against partial-fix regressions. + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + + var headerBlock = EncodeMinimalHeaders(); + + var headersFrame = BuildHeadersFrame(streamId: 1, headerBlock, endStream: false, endHeaders: true); + sm.DecodeClientData(WrapFrame(headersFrame)); + Assert.Equal(1, sm.ActiveStreamCount); + + ops.CancelledTimers.Clear(); + + sm.EmitRstStream(streamId: 1, Http2ErrorCode.InternalError); + + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("headers-timeout:1")); + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("body-consumption:1")); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.4")] + public void CloseStream_via_refused_stream_should_cancel_headers_timeout_timer() + { + // When MaxConcurrentStreams=1 is reached, stream N+1 is closed via EmitRstStream. + // Verify the headers-timeout timer for that stream is cancelled. + var ops = new FakeServerOps(); + var baseOptions = new TurboServerOptions { Http2 = { MaxConcurrentStreams = 1 } }; + var sm = new Http2ServerSessionManager(baseOptions.ToHttp2Options(), ops); + sm.PreStart(); + ops.Outbound.Clear(); + ops.ScheduledTimers.Clear(); + ops.CancelledTimers.Clear(); + + var headerBlock = EncodeMinimalHeaders(); + + // Stream 1 occupies the only slot + var headers1 = BuildHeadersFrame(streamId: 1, headerBlock, endStream: false, endHeaders: true); + sm.DecodeClientData(WrapFrame(headers1)); + Assert.Equal(1, sm.ActiveStreamCount); + + ops.CancelledTimers.Clear(); + + // Server-side RST_STREAM — timer must be cancelled + sm.EmitRstStream(streamId: 1, Http2ErrorCode.Cancel); + + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("headers-timeout:1")); + Assert.Equal(0, sm.ActiveStreamCount); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.4")] + public void CloseStream_on_multiple_streams_should_cancel_respective_timer_keys() + { + // Each stream's CloseStream call must cancel that stream's own timer keys, + // not a shared key — verifies per-stream key naming. + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + + var headerBlock = EncodeMinimalHeaders(); + + var headers1 = BuildHeadersFrame(streamId: 1, headerBlock, endStream: false, endHeaders: true); + sm.DecodeClientData(WrapFrame(headers1)); + + var headers3 = BuildHeadersFrame(streamId: 3, headerBlock, endStream: false, endHeaders: true); + sm.DecodeClientData(WrapFrame(headers3)); + + Assert.Equal(2, sm.ActiveStreamCount); + + ops.CancelledTimers.Clear(); + + sm.EmitRstStream(streamId: 1, Http2ErrorCode.Cancel); + Assert.Contains(ops.CancelledTimers, name => name == "headers-timeout:1"); + Assert.DoesNotContain(ops.CancelledTimers, name => name == "headers-timeout:3"); + + ops.CancelledTimers.Clear(); + + sm.EmitRstStream(streamId: 3, Http2ErrorCode.Cancel); + Assert.Contains(ops.CancelledTimers, name => name == "headers-timeout:3"); + Assert.DoesNotContain(ops.CancelledTimers, name => name == "headers-timeout:1"); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2RapidResetSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2RapidResetSpec.cs new file mode 100644 index 000000000..d293fa6a3 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2RapidResetSpec.cs @@ -0,0 +1,87 @@ +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.SessionManager; + +public sealed class Http2RapidResetSpec +{ + private static byte[] BuildRstStream(int streamId, Http2ErrorCode code = Http2ErrorCode.Cancel) + { + var frame = new byte[9 + 4]; + frame[2] = 4; // payload length + frame[3] = (byte)FrameType.RstStream; + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + var c = (uint)code; + frame[9] = (byte)(c >> 24); + frame[10] = (byte)(c >> 16); + frame[11] = (byte)(c >> 8); + frame[12] = (byte)c; + return frame; + } + + private static TransportBuffer WrapFrame(byte[] frame) + { + var buffer = TransportBuffer.Rent(frame.Length); + frame.CopyTo(buffer.FullMemory.Span); + buffer.Length = frame.Length; + return buffer; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1")] + public void Excessive_stream_resets_should_emit_goaway_enhance_your_calm() + { + // CVE-2023-44487 (Rapid Reset): a client that opens-and-resets streams faster than a threshold + // must be cut off with GOAWAY(ENHANCE_YOUR_CALM); MaxConcurrentStreams alone never saturates. + var ops = new FakeServerOps(); + var options = new TurboServerOptions { Limits = { MaxResetStreamsPerWindow = 5 } }; + var sm = new Http2ServerSessionManager(options.ToHttp2Options(), ops); + sm.PreStart(); + ops.Outbound.Clear(); + + for (var i = 0; i < 6; i++) + { + sm.DecodeClientData(WrapFrame(BuildRstStream(1 + i * 2))); + } + + Assert.True(sm.ShouldComplete); + + TransportData? goAway = null; + foreach (var item in ops.Outbound) + { + if (item is TransportData td && td.Buffer.Span[3] == (byte)FrameType.GoAway) + { + goAway = td; + } + } + + Assert.NotNull(goAway); + var s = goAway.Buffer.Span; + var code = (s[13] << 24) | (s[14] << 16) | (s[15] << 8) | s[16]; + Assert.Equal((int)Http2ErrorCode.EnhanceYourCalm, code); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.1")] + public void Resets_below_threshold_should_not_terminate_the_connection() + { + var ops = new FakeServerOps(); + var options = new TurboServerOptions { Limits = { MaxResetStreamsPerWindow = 5 } }; + var sm = new Http2ServerSessionManager(options.ToHttp2Options(), ops); + sm.PreStart(); + ops.Outbound.Clear(); + + for (var i = 0; i < 4; i++) + { + sm.DecodeClientData(WrapFrame(BuildRstStream(1 + i * 2))); + } + + Assert.False(sm.ShouldComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ServerTrailerSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ServerTrailerSpec.cs new file mode 100644 index 000000000..cf0c9e804 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2ServerTrailerSpec.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Http.Features; +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.SessionManager; + +public sealed class Http2ServerTrailerSpec +{ + private static byte[] BuildHeadersFrame(int streamId, List headers, bool endStream, HpackEncoder encoder) + { + var buf = new byte[4096]; + var span = buf.AsSpan(); + var written = encoder.Encode(headers, ref span, useHuffman: false); + var block = new Memory(buf, 0, written); + + const int h = 9; + var frame = new byte[h + block.Length]; + var len = block.Length; + frame[0] = (byte)(len >> 16); + frame[1] = (byte)(len >> 8); + frame[2] = (byte)len; + frame[3] = (byte)FrameType.Headers; + byte flags = 0x04; // END_HEADERS + if (endStream) + { + flags |= 0x01; + } + + frame[4] = flags; + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + block.Span.CopyTo(frame.AsSpan(h)); + return frame; + } + + private static byte[] BuildDataFrame(int streamId, byte[] data, bool endStream) + { + const int h = 9; + var frame = new byte[h + data.Length]; + var len = data.Length; + frame[0] = (byte)(len >> 16); + frame[1] = (byte)(len >> 8); + frame[2] = (byte)len; + frame[3] = (byte)FrameType.Data; + frame[4] = endStream ? (byte)0x01 : (byte)0x00; + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + data.CopyTo(frame, h); + return frame; + } + + private static TransportBuffer WrapFrame(byte[] frame) + { + var buffer = TransportBuffer.Rent(frame.Length); + frame.CopyTo(buffer.FullMemory.Span); + buffer.Length = frame.Length; + return buffer; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public void HandleHeadersFrame_should_not_emit_second_request_for_trailers() + { + var ops = new FakeServerOps(); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var encoder = new HpackEncoder(useHuffman: false); + var sm = new Http2ServerSessionManager(options, ops); + sm.PreStart(); + ops.Outbound.Clear(); + + var requestHeaders = new List + { + new(":method", "POST"), + new(":path", "/upload"), + new(":scheme", "https"), + new(":authority", "localhost"), + new("content-type", "application/octet-stream"), + }; + var headersFrame = BuildHeadersFrame(1, requestHeaders, endStream: false, encoder); + sm.DecodeClientData(WrapFrame(headersFrame)); + + Assert.Single(ops.Requests); + + var dataFrame = BuildDataFrame(1, "hello"u8.ToArray(), endStream: false); + sm.DecodeClientData(WrapFrame(dataFrame)); + + var trailerHeaders = new List + { + new("x-checksum", "abc123"), + }; + var trailerFrame = BuildHeadersFrame(1, trailerHeaders, endStream: true, encoder); + sm.DecodeClientData(WrapFrame(trailerFrame)); + + Assert.Single(ops.Requests); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-8.1")] + public async Task HandleHeadersFrame_should_complete_body_on_trailer_endstream() + { + var ops = new FakeServerOps(); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var encoder = new HpackEncoder(useHuffman: false); + var sm = new Http2ServerSessionManager(options, ops); + sm.PreStart(); + ops.Outbound.Clear(); + + var requestHeaders = new List + { + new(":method", "POST"), + new(":path", "/upload"), + new(":scheme", "https"), + new(":authority", "localhost"), + }; + var headersFrame = BuildHeadersFrame(1, requestHeaders, endStream: false, encoder); + sm.DecodeClientData(WrapFrame(headersFrame)); + + var dataFrame = BuildDataFrame(1, "data"u8.ToArray(), endStream: false); + sm.DecodeClientData(WrapFrame(dataFrame)); + + var request = ops.Requests[0]; + var body = request.Get()!.Body; + Assert.NotNull(body); + + var readBuffer = new byte[64]; + var readTask = body.ReadAsync(readBuffer, 0, readBuffer.Length, TestContext.Current.CancellationToken); + + var trailerHeaders = new List + { + new("x-trailer", "value"), + }; + var trailerFrame = BuildHeadersFrame(1, trailerHeaders, endStream: true, encoder); + sm.DecodeClientData(WrapFrame(trailerFrame)); + + var read = await readTask; + Assert.Equal(4, read); + Assert.Equal("data"u8.ToArray(), readBuffer[..read]); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2SettingsGoawaySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2SettingsGoawaySpec.cs index fcb04a49e..3ba2f926b 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2SettingsGoawaySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2SettingsGoawaySpec.cs @@ -1,6 +1,5 @@ using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http2; -using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Server; using TurboHTTP.Tests.Shared; @@ -11,10 +10,9 @@ public sealed class Http2SettingsGoawaySpec { private static Http2ServerSessionManager CreateSessionManager(FakeServerOps ops) { - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); var options = new TurboServerOptions(); - return new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var h2Options = options.ToHttp2Options(); + return new Http2ServerSessionManager(h2Options, ops); } private static byte[] BuildSettingsFrame(bool isAck = false) @@ -206,15 +204,13 @@ public void GoAway_should_not_crash() public void PreStart_should_emit_settings_with_configured_stream_window_size() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); const int customStreamWindow = 256 * 1024; var options = new TurboServerOptions { Http2 = { InitialStreamWindowSize = customStreamWindow } }; - var sessionManager = new Http2ServerSessionManager( - encoderOptions, decoderOptions, ops, options); + var h2Options = options.ToHttp2Options(); + var sessionManager = new Http2ServerSessionManager(h2Options, ops); sessionManager.PreStart(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2StreamLifecycleSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2StreamLifecycleSpec.cs index b2c541aae..f03d5e23f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2StreamLifecycleSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/SessionManager/Http2StreamLifecycleSpec.cs @@ -2,7 +2,6 @@ using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http2; using TurboHTTP.Protocol.Syntax.Http2.Hpack; -using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; @@ -91,11 +90,10 @@ private static TransportBuffer WrapFrame(byte[] frame) public void Should_accept_streams_up_to_max_concurrent() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions { MaxConcurrentStreams = 2 }; + var baseOptions = new TurboServerOptions { Http2 = { MaxConcurrentStreams = 2 } }; + var options = baseOptions.ToHttp2Options(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); // Clear initial SETTINGS frame @@ -123,11 +121,10 @@ public void Should_accept_streams_up_to_max_concurrent() public void Should_refuse_stream_above_max_concurrent() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions { MaxConcurrentStreams = 1 }; + var baseOptions = new TurboServerOptions { Http2 = { MaxConcurrentStreams = 1 } }; + var options = baseOptions.ToHttp2Options(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); // Clear initial SETTINGS frame @@ -176,11 +173,10 @@ public void Should_refuse_stream_above_max_concurrent() public void RstStream_on_active_stream_should_close_it() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); @@ -207,10 +203,9 @@ public void RstStream_on_active_stream_should_close_it() public void RstStream_on_closed_stream_should_not_crash() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); @@ -232,10 +227,9 @@ public void RstStream_on_closed_stream_should_not_crash() public void Headers_with_EndStream_true_should_emit_request_immediately() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); @@ -259,10 +253,9 @@ public void Headers_with_EndStream_true_should_emit_request_immediately() public void Cleanup_should_be_idempotent() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); @@ -288,10 +281,9 @@ public void Cleanup_should_be_idempotent() public void OnResponse_for_unknown_stream_should_not_crash() { var ops = new FakeServerOps(); - var encoderOptions = new Http2ServerEncoderOptions(); - var decoderOptions = new Http2ServerDecoderOptions(); - var options = new TurboServerOptions(); - var sm = new Http2ServerSessionManager(encoderOptions, decoderOptions, ops, options); + var baseOptions = new TurboServerOptions(); + var options = baseOptions.ToHttp2Options(); + var sm = new Http2ServerSessionManager(options, ops); sm.PreStart(); ops.Outbound.Clear(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2KeepAliveCloseSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2KeepAliveCloseSpec.cs new file mode 100644 index 000000000..0f928a3bd --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2KeepAliveCloseSpec.cs @@ -0,0 +1,21 @@ +using TurboHTTP.Protocol.Syntax.Http2.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.StateMachine; + +public sealed class Http2KeepAliveCloseSpec +{ + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-5.5")] + public void OnTimerFired_should_set_ShouldComplete_on_keepalive_timeout() + { + var ops = new FakeServerOps(); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); + sm.PreStart(); + + sm.OnTimerFired("keep-alive-timeout"); + + Assert.True(sm.ShouldComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerSettingsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerSettingsSpec.cs index be308e317..bece70a5e 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerSettingsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerSettingsSpec.cs @@ -1,4 +1,5 @@ using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Tests.Shared; using Microsoft.AspNetCore.Http.Features; @@ -7,11 +8,20 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.StateMachine; public sealed class Http2ServerSettingsSpec { + private static Http2ServerEncoderOptions DefaultEncoderOptions() => new() + { + MaxFrameSize = 16 * 1024, + HeaderTableSize = 4096, + WriteDateHeader = false, + MaxHeaderBytes = 32 * 1024, + UseHuffman = true + }; + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-6.5")] public void ApplyClientSettings_updates_max_frame_size() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); // Verify default max frame size Assert.Equal(16384, encoder.MaxFrameSize); @@ -27,7 +37,7 @@ public void ApplyClientSettings_updates_max_frame_size() [Trait("RFC", "RFC9113-6.5")] public void ApplyClientSettings_updates_header_table_size() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); var settings = new[] { (SettingsParameter.HeaderTableSize, (uint)8192) }; encoder.ApplyClientSettings(settings); @@ -44,7 +54,7 @@ public void ApplyClientSettings_updates_header_table_size() [Trait("RFC", "RFC9113-6.5")] public void Default_max_frame_size_is_16384() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); Assert.Equal(16384, encoder.MaxFrameSize); } @@ -53,7 +63,7 @@ public void Default_max_frame_size_is_16384() [Trait("RFC", "RFC9113-6.5")] public void ResetHpack_allows_encoder_reuse() { - var encoder = new Http2ServerEncoder(); + var encoder = new Http2ServerEncoder(DefaultEncoderOptions()); var ctx1 = ServerTestContext.CreateResponse(); ctx1.Get()?.Headers["x-header"] = "value1"; diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStateMachineSpec.cs index 232041249..c5125fbf2 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStateMachineSpec.cs @@ -69,7 +69,7 @@ private static byte[] BuildPingFrame(bool isAck = false) frame[1] = 0; frame[2] = pingDataSize; frame[3] = (byte)FrameType.Ping; - frame[4] = isAck ? (byte)PingFlags.Ack : (byte)0; + frame[4] = isAck ? (byte)Pings.Ack : (byte)0; frame[5] = 0; frame[6] = 0; frame[7] = 0; @@ -107,7 +107,7 @@ private static ReadOnlyMemory EncodeHeaders(string method, string path, st public void PreStart_should_emit_settings_frame() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); @@ -121,7 +121,7 @@ public void PreStart_should_emit_settings_frame() public void DecodeClientData_with_headers_should_produce_request_with_stream_id() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); var headerBlock = EncodeHeaders("GET", "/", "example.com"); var headersFrameData = BuildHeadersFrame(streamId: 1, headerBlock, endStream: true, endHeaders: true); @@ -130,7 +130,7 @@ public void DecodeClientData_with_headers_should_produce_request_with_stream_id( headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Requests); var context = ops.Requests[0]; @@ -150,7 +150,7 @@ public void DecodeClientData_with_headers_should_produce_request_with_stream_id( public void DecodeClientData_with_headers_incomplete_should_not_emit_request_until_end_headers() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); var headerBlock = EncodeHeaders("GET", "/", "example.com"); // Split header block: first part without EndHeaders @@ -165,7 +165,7 @@ public void DecodeClientData_with_headers_incomplete_should_not_emit_request_unt headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // No request emitted yet, waiting for CONTINUATION Assert.Empty(ops.Requests); @@ -176,7 +176,7 @@ public void DecodeClientData_with_headers_incomplete_should_not_emit_request_unt public void DecodeClientData_with_ping_should_echo_ack() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -186,7 +186,7 @@ public void DecodeClientData_with_ping_should_echo_ack() pingFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = pingFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Outbound); var outbound = ops.Outbound[0]; @@ -197,7 +197,7 @@ public void DecodeClientData_with_ping_should_echo_ack() // Frame type should be PING (0x6), flags should include ACK (0x1) Assert.Equal((byte)FrameType.Ping, responseData[3]); - Assert.True((responseData[4] & (byte)PingFlags.Ack) != 0); + Assert.True((responseData[4] & (byte)Pings.Ack) != 0); } [Fact(Timeout = 5000)] @@ -205,7 +205,7 @@ public void DecodeClientData_with_ping_should_echo_ack() public void DecodeClientData_with_settings_should_ack() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -215,7 +215,7 @@ public void DecodeClientData_with_settings_should_ack() settingsFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = settingsFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Outbound); var outbound = ops.Outbound[0]; @@ -234,7 +234,7 @@ public void DecodeClientData_with_settings_should_ack() public void OnResponse_should_encode_and_emit_frames() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Receive a request first var headerBlock = EncodeHeaders("GET", "/", "example.com"); @@ -244,7 +244,7 @@ public void OnResponse_should_encode_and_emit_frames() headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Requests); @@ -267,7 +267,7 @@ public void OnResponse_should_encode_and_emit_frames() public void CanAcceptResponse_should_be_true_when_request_received() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); Assert.False(sm.CanAcceptResponse); @@ -278,7 +278,7 @@ public void CanAcceptResponse_should_be_true_when_request_received() headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.True(sm.CanAcceptResponse); } @@ -288,7 +288,7 @@ public void CanAcceptResponse_should_be_true_when_request_received() public void Cleanup_should_dispose_decoder() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStreamCorrelationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStreamCorrelationSpec.cs index 0e038d270..8b94f3695 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStreamCorrelationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerStreamCorrelationSpec.cs @@ -74,7 +74,7 @@ private static byte[] BuildHeadersFrame(int streamId, ReadOnlyMemory heade public void Multiple_concurrent_streams_should_correlate_responses_to_correct_stream_ids() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send HEADERS on stream 1 var headerBlock1 = EncodeHeaders("GET", "/path1", "example.com"); @@ -84,7 +84,7 @@ public void Multiple_concurrent_streams_should_correlate_responses_to_correct_st headersFrameData1.CopyTo(buffer1.FullMemory.Span); buffer1.Length = headersFrameData1.Length; - sm.DecodeClientData(new TransportData(buffer1)); + sm.DecodeClientData(TransportData.Rent(buffer1)); // Send HEADERS on stream 3 var headerBlock3 = EncodeHeaders("GET", "/path3", "example.com"); @@ -94,7 +94,7 @@ public void Multiple_concurrent_streams_should_correlate_responses_to_correct_st headersFrameData3.CopyTo(buffer3.FullMemory.Span); buffer3.Length = headersFrameData3.Length; - sm.DecodeClientData(new TransportData(buffer3)); + sm.DecodeClientData(TransportData.Rent(buffer3)); // Verify both requests were emitted Assert.Equal(2, ops.Requests.Count); @@ -176,7 +176,7 @@ public void Multiple_concurrent_streams_should_correlate_responses_to_correct_st public void Stream_IDs_should_preserve_request_response_correlation_across_interleaved_processing() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send three requests on streams 1, 3, 5 for (var streamId = 1; streamId <= 5; streamId += 2) @@ -188,7 +188,7 @@ public void Stream_IDs_should_preserve_request_response_correlation_across_inter headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); } Assert.Equal(3, ops.Requests.Count); @@ -250,7 +250,7 @@ public void Stream_IDs_should_preserve_request_response_correlation_across_inter public void Concurrent_streams_should_maintain_independent_state() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send multiple requests without waiting for responses var headerBlock1 = EncodeHeaders("GET", "/"); @@ -264,17 +264,17 @@ public void Concurrent_streams_should_maintain_independent_state() var buf1 = TransportBuffer.Rent(headersData1.Length); headersData1.CopyTo(buf1.FullMemory.Span); buf1.Length = headersData1.Length; - sm.DecodeClientData(new TransportData(buf1)); + sm.DecodeClientData(TransportData.Rent(buf1)); var buf2 = TransportBuffer.Rent(headersData2.Length); headersData2.CopyTo(buf2.FullMemory.Span); buf2.Length = headersData2.Length; - sm.DecodeClientData(new TransportData(buf2)); + sm.DecodeClientData(TransportData.Rent(buf2)); var buf3 = TransportBuffer.Rent(headersData3.Length); headersData3.CopyTo(buf3.FullMemory.Span); buf3.Length = headersData3.Length; - sm.DecodeClientData(new TransportData(buf3)); + sm.DecodeClientData(TransportData.Rent(buf3)); // All three requests should have been emitted Assert.Equal(3, ops.Requests.Count); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerTimerErrorSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerTimerErrorSpec.cs index 3718e88ef..6d83d047a 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerTimerErrorSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/StateMachine/Http2ServerTimerErrorSpec.cs @@ -62,7 +62,7 @@ private static TransportData WrapAsTransportData(byte[] frameData) var buffer = TransportBuffer.Rent(frameData.Length); frameData.CopyTo(buffer.FullMemory.Span); buffer.Length = frameData.Length; - return new TransportData(buffer); + return TransportData.Rent(buffer); } [Fact(Timeout = 5000)] @@ -70,7 +70,7 @@ private static TransportData WrapAsTransportData(byte[] frameData) public void PreStart_should_schedule_keep_alive_timer() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); @@ -85,7 +85,7 @@ public void PreStart_should_schedule_keep_alive_timer() public void OnTimerFired_keep_alive_should_emit_GoAway() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -108,7 +108,7 @@ public void OnTimerFired_keep_alive_should_emit_GoAway() public void ShouldComplete_should_always_be_false() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); Assert.False(sm.ShouldComplete); @@ -129,7 +129,7 @@ public void ShouldComplete_should_always_be_false() public void DecodeClientData_should_cancel_keep_alive_when_streams_open() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.CancelledTimers.Clear(); @@ -148,7 +148,7 @@ public void DecodeClientData_should_cancel_keep_alive_when_streams_open() public void OnTimerFired_headers_timeout_should_emit_RstStream() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -171,13 +171,14 @@ public void OnTimerFired_headers_timeout_should_emit_RstStream() public void Cleanup_should_be_idempotent() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); // Should not throw when called multiple times sm.Cleanup(); sm.Cleanup(); + Assert.True(true); } [Fact(Timeout = 5000)] @@ -185,12 +186,14 @@ public void Cleanup_should_be_idempotent() public void OnResponse_for_unknown_stream_should_not_crash() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); // Should not throw when responding on unknown stream var context = CreateResponseContext(); sm.OnResponse(context); + + Assert.True(true); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerBodyStreamingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerBodyStreamingSpec.cs index 780282f19..c2b3a8020 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerBodyStreamingSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerBodyStreamingSpec.cs @@ -51,7 +51,7 @@ private static byte[] BuildDataFrame(int streamId, byte[] data, bool endStream = frame[3] = (byte)FrameType.Data; byte flags = 0; - if (endStream) flags |= (byte)DataFlags.EndStream; + if (endStream) flags |= (byte)Datas.EndStream; frame[4] = flags; frame[5] = (byte)(streamId >> 24); @@ -87,7 +87,7 @@ private static ReadOnlyMemory EncodeHeaders(string method, string path, st public async Task DecodeClientData_with_body_should_emit_request_on_headers_with_streaming_content() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send HEADERS frame with endStream=false (body will follow) var headerBlock = EncodeHeaders("POST", "/api/data", "example.com"); @@ -97,7 +97,7 @@ public async Task DecodeClientData_with_body_should_emit_request_on_headers_with headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Request should be emitted immediately Assert.Single(ops.Requests); @@ -116,7 +116,7 @@ public async Task DecodeClientData_with_body_should_emit_request_on_headers_with dataFrameData.CopyTo(buffer2.FullMemory.Span); buffer2.Length = dataFrameData.Length; - sm.DecodeClientData(new TransportData(buffer2)); + sm.DecodeClientData(TransportData.Rent(buffer2)); // Read from body stream using var stream = new MemoryStream(); @@ -130,7 +130,7 @@ public async Task DecodeClientData_with_body_should_emit_request_on_headers_with public void DecodeClientData_headers_only_should_emit_request_without_pipe_content() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send HEADERS frame with endStream=true (no body) var headerBlock = EncodeHeaders("GET", "/api/status", "example.com"); @@ -140,7 +140,7 @@ public void DecodeClientData_headers_only_should_emit_request_without_pipe_conte headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Request should be emitted Assert.Single(ops.Requests); @@ -167,7 +167,7 @@ public void DecodeClientData_exceeding_max_body_size_should_emit_rst_stream() MaxRequestBodySize = maxBodySize } }; - var sm = new Http2ServerStateMachine(options, ops); + var sm = new Http2ServerStateMachine(options.ToHttp2Options(), ops); // Send HEADERS frame with endStream=false var headerBlock = EncodeHeaders("POST", "/api/upload", "example.com"); @@ -177,7 +177,7 @@ public void DecodeClientData_exceeding_max_body_size_should_emit_rst_stream() headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Request should be emitted Assert.Single(ops.Requests); @@ -192,7 +192,7 @@ public void DecodeClientData_exceeding_max_body_size_should_emit_rst_stream() dataFrameData.CopyTo(buffer2.FullMemory.Span); buffer2.Length = dataFrameData.Length; - sm.DecodeClientData(new TransportData(buffer2)); + sm.DecodeClientData(TransportData.Rent(buffer2)); // RST_STREAM should have been emitted (or possibly other control frames too) var newOutbound = ops.Outbound.Skip(initialOutboundCount).ToList(); @@ -214,7 +214,7 @@ public void DecodeClientData_exceeding_max_body_size_should_emit_rst_stream() public async Task DecodeClientData_with_multiple_data_frames_should_aggregate_in_pipe() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send HEADERS frame with endStream=false var headerBlock = EncodeHeaders("POST", "/api/stream", "example.com"); @@ -224,7 +224,7 @@ public async Task DecodeClientData_with_multiple_data_frames_should_aggregate_in headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Requests); var context = ops.Requests[0]; @@ -239,7 +239,12 @@ public async Task DecodeClientData_with_multiple_data_frames_should_aggregate_in dataFrame1.CopyTo(buffer1.FullMemory.Span); buffer1.Length = dataFrame1.Length; - sm.DecodeClientData(new TransportData(buffer1)); + sm.DecodeClientData(TransportData.Rent(buffer1)); + + // Consume first chunk (backpressure contract: AdvanceTo before next Supply) + var buf1 = new byte[64]; + var read1 = await bodyStream.ReadAsync(buf1, TestContext.Current.CancellationToken); + Assert.Equal("First ", System.Text.Encoding.UTF8.GetString(buf1, 0, read1)); // Send second DATA frame with endStream=true var data2 = "Second"u8.ToArray(); @@ -249,13 +254,12 @@ public async Task DecodeClientData_with_multiple_data_frames_should_aggregate_in dataFrame2.CopyTo(buffer2.FullMemory.Span); buffer2.Length = dataFrame2.Length; - sm.DecodeClientData(new TransportData(buffer2)); + sm.DecodeClientData(TransportData.Rent(buffer2)); - // Read aggregated data from body stream - using var stream = new MemoryStream(); - await bodyStream.CopyToAsync(stream, TestContext.Current.CancellationToken); - var receivedData = System.Text.Encoding.UTF8.GetString(stream.ToArray()); - Assert.Equal("First Second", receivedData); + // Read second chunk + var buf2 = new byte[64]; + var read2 = await bodyStream.ReadAsync(buf2, TestContext.Current.CancellationToken); + Assert.Equal("Second", System.Text.Encoding.UTF8.GetString(buf2, 0, read2)); } [Fact(Timeout = 5000)] @@ -263,7 +267,7 @@ public async Task DecodeClientData_with_multiple_data_frames_should_aggregate_in public void DecodeClientData_with_rst_stream_should_complete_body_writer_with_cancellation() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); // Send HEADERS frame with endStream=false var headerBlock = EncodeHeaders("POST", "/api/upload", "example.com"); @@ -273,7 +277,7 @@ public void DecodeClientData_with_rst_stream_should_complete_body_writer_with_ca headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); Assert.Single(ops.Requests); var context = ops.Requests[0]; @@ -288,7 +292,7 @@ public void DecodeClientData_with_rst_stream_should_complete_body_writer_with_ca dataFrame.CopyTo(buffer1.FullMemory.Span); buffer1.Length = dataFrame.Length; - sm.DecodeClientData(new TransportData(buffer1)); + sm.DecodeClientData(TransportData.Rent(buffer1)); // Now send RST_STREAM const int frameHeaderSize = 9; @@ -316,6 +320,6 @@ public void DecodeClientData_with_rst_stream_should_complete_body_writer_with_ca rstData.CopyTo(buffer2.FullMemory.Span); buffer2.Length = rstData.Length; - sm.DecodeClientData(new TransportData(buffer2)); + sm.DecodeClientData(TransportData.Rent(buffer2)); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerFlowControlSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerFlowControlSpec.cs index d7ad7b89e..834ba150c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerFlowControlSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerFlowControlSpec.cs @@ -24,7 +24,7 @@ private static byte[] BuildDataFrame(int streamId, byte[] data, bool endStream = frame[3] = (byte)FrameType.Data; byte flags = 0; - if (endStream) flags |= (byte)DataFlags.EndStream; + if (endStream) flags |= (byte)Datas.EndStream; frame[4] = flags; frame[5] = (byte)(streamId >> 24); @@ -113,7 +113,7 @@ private static byte[] BuildHeadersFrame(int streamId, ReadOnlyMemory heade [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-5.1.2")] - public void DecodeClientData_with_data_frame_should_emit_window_update_when_threshold_reached() + public async Task DecodeClientData_with_data_frame_should_emit_window_update_when_threshold_reached() { // Create SM with small window so we can easily exceed threshold const int initialWindowSize = 16384; @@ -127,7 +127,7 @@ public void DecodeClientData_with_data_frame_should_emit_window_update_when_thre InitialStreamWindowSize = initialWindowSize } }; - var sm = new Http2ServerStateMachine(options, ops); + var sm = new Http2ServerStateMachine(options.ToHttp2Options(), ops); // Send HEADERS on stream 1 with endStream=false to accept body data var headerBlock = EncodeHeaders("POST", "/upload", "example.com"); @@ -137,7 +137,7 @@ public void DecodeClientData_with_data_frame_should_emit_window_update_when_thre headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Request should be emitted immediately when headers arrive (with endStream=false) Assert.Single(ops.Requests); @@ -155,7 +155,13 @@ public void DecodeClientData_with_data_frame_should_emit_window_update_when_thre dataFrameData1.CopyTo(dataBuf1.FullMemory.Span); dataBuf1.Length = dataFrameData1.Length; - sm.DecodeClientData(new TransportData(dataBuf1)); + sm.DecodeClientData(TransportData.Rent(dataBuf1)); + + var bodyStream = context.Get()?.Body; + Assert.NotNull(bodyStream); + var drain1 = new byte[1024]; + var bytesRead = await bodyStream.ReadAsync(drain1, TestContext.Current.CancellationToken); + Assert.Equal(1000, bytesRead); // No window update yet (threshold not exceeded) ops.Requests.Clear(); @@ -180,7 +186,7 @@ public void DecodeClientData_with_data_frame_should_emit_window_update_when_thre dataFrameData2.CopyTo(dataBuf2.FullMemory.Span); dataBuf2.Length = dataFrameData2.Length; - sm.DecodeClientData(new TransportData(dataBuf2)); + sm.DecodeClientData(TransportData.Rent(dataBuf2)); // Now verify WINDOW_UPDATE was emitted for stream 1 Assert.NotEmpty(ops.Outbound); @@ -212,7 +218,7 @@ public void DecodeClientData_with_data_frame_should_emit_window_update_when_thre public void DecodeClientData_with_window_update_should_not_emit_goaway() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -225,7 +231,7 @@ public void DecodeClientData_with_window_update_should_not_emit_goaway() buffer.Length = windowUpdateData.Length; // This should not throw or emit GOAWAY - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Verify no GOAWAY was emitted var hasGoAway = false; @@ -247,7 +253,7 @@ public void DecodeClientData_with_window_update_should_not_emit_goaway() [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-5.1.2")] - public void DecodeClientData_with_multiple_data_frames_should_track_window_correctly() + public async Task DecodeClientData_with_multiple_data_frames_should_track_window_correctly() { const int initialWindowSize = 20000; var ops = new FakeServerOps(); @@ -260,7 +266,7 @@ public void DecodeClientData_with_multiple_data_frames_should_track_window_corre InitialStreamWindowSize = initialWindowSize } }; - var sm = new Http2ServerStateMachine(options, ops); + var sm = new Http2ServerStateMachine(options.ToHttp2Options(), ops); // Send HEADERS var headerBlock = EncodeHeaders("POST", "/", "example.com"); @@ -270,7 +276,10 @@ public void DecodeClientData_with_multiple_data_frames_should_track_window_corre headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); + + var bodyStream = ops.Requests[0].Get()?.Body; + Assert.NotNull(bodyStream); ops.Outbound.Clear(); // Send first DATA frame (5000 bytes) @@ -279,7 +288,11 @@ public void DecodeClientData_with_multiple_data_frames_should_track_window_corre var buf1 = TransportBuffer.Rent(frame1Data.Length); frame1Data.CopyTo(buf1.FullMemory.Span); buf1.Length = frame1Data.Length; - sm.DecodeClientData(new TransportData(buf1)); + sm.DecodeClientData(TransportData.Rent(buf1)); + + // Consume body data (backpressure contract) + var drain1 = new byte[5000]; + await bodyStream.ReadExactlyAsync(drain1, TestContext.Current.CancellationToken); // Send second DATA frame (6000 bytes) - should exceed half window var data2 = new byte[6000]; @@ -287,7 +300,7 @@ public void DecodeClientData_with_multiple_data_frames_should_track_window_corre var buf2 = TransportBuffer.Rent(frame2Data.Length); frame2Data.CopyTo(buf2.FullMemory.Span); buf2.Length = frame2Data.Length; - sm.DecodeClientData(new TransportData(buf2)); + sm.DecodeClientData(TransportData.Rent(buf2)); // Should have emitted at least one WINDOW_UPDATE var windowUpdateCount = ops.Outbound.Count(item => diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerOutboundFrameSplittingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerOutboundFrameSplittingSpec.cs new file mode 100644 index 000000000..6c9a601b9 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerOutboundFrameSplittingSpec.cs @@ -0,0 +1,275 @@ +using Microsoft.AspNetCore.Http.Features; +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http2; +using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Streaming; + +public sealed class Http2ServerOutboundFrameSplittingSpec +{ + private static byte[] BuildHeadersFrame(int streamId, ReadOnlyMemory headerBlock, bool endStream = false, + bool endHeaders = true) + { + const int frameHeaderSize = 9; + var frameSize = frameHeaderSize + headerBlock.Length; + var frame = new byte[frameSize]; + + var length = headerBlock.Length; + frame[0] = (byte)(length >> 16); + frame[1] = (byte)(length >> 8); + frame[2] = (byte)length; + frame[3] = (byte)FrameType.Headers; + + byte flags = 0; + if (endStream) flags |= (byte)Headers.EndStream; + if (endHeaders) flags |= (byte)Headers.EndHeaders; + frame[4] = flags; + + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + + headerBlock.Span.CopyTo(frame.AsSpan(frameHeaderSize)); + + return frame; + } + + private static byte[] BuildSettingsFrameWithMaxFrameSize(uint maxFrameSize) + { + const int frameHeaderSize = 9; + const int paramSize = 6; + var frame = new byte[frameHeaderSize + paramSize]; + + frame[0] = 0; + frame[1] = 0; + frame[2] = paramSize; + frame[3] = (byte)FrameType.Settings; + frame[4] = 0; + + var key = (ushort)SettingsParameter.MaxFrameSize; + frame[9] = (byte)(key >> 8); + frame[10] = (byte)key; + frame[11] = (byte)(maxFrameSize >> 24); + frame[12] = (byte)(maxFrameSize >> 16); + frame[13] = (byte)(maxFrameSize >> 8); + frame[14] = (byte)maxFrameSize; + + return frame; + } + + private static byte[] BuildWindowUpdateFrame(int streamId, uint increment) + { + const int frameHeaderSize = 9; + const int windowUpdateSize = 4; + var frame = new byte[frameHeaderSize + windowUpdateSize]; + + frame[0] = 0; + frame[1] = 0; + frame[2] = windowUpdateSize; + frame[3] = (byte)FrameType.WindowUpdate; + + frame[5] = (byte)(streamId >> 24); + frame[6] = (byte)(streamId >> 16); + frame[7] = (byte)(streamId >> 8); + frame[8] = (byte)streamId; + + var incValue = increment & 0x7FFFFFFF; + frame[9] = (byte)(incValue >> 24); + frame[10] = (byte)(incValue >> 16); + frame[11] = (byte)(incValue >> 8); + frame[12] = (byte)incValue; + + return frame; + } + + private static ReadOnlyMemory EncodeHeaders(string method, string path, string authority = "localhost") + { + var encoder = new HpackEncoder(useHuffman: true); + var headers = new List + { + new(":method", method), + new(":path", path), + new(":scheme", "https"), + new(":authority", authority), + }; + + var buffer = new byte[4096]; + var span = buffer.AsSpan(); + var written = encoder.Encode(headers, ref span, useHuffman: true); + + return new Memory(buffer, 0, written); + } + + private static void DecodeFramesAsStream(Http2ServerStateMachine sm, byte[] frameData) + { + var buffer = TransportBuffer.Rent(frameData.Length); + frameData.CopyTo(buffer.FullMemory.Span); + buffer.Length = frameData.Length; + sm.DecodeClientData(TransportData.Rent(buffer)); + } + + private static List ExtractFrames(List outbound, int startIndex = 0) + { + var frames = new List(); + var decoder = new FrameDecoder(); + + for (var i = startIndex; i < outbound.Count; i++) + { + if (outbound[i] is TransportData td) + { + var decodedFrames = decoder.Decode(td.Buffer); + frames.AddRange(decodedFrames); + } + } + + return frames; + } + + private static Http2ServerStateMachine CreateSmWithClientMaxFrameSize( + FakeServerOps ops, uint clientMaxFrameSize, int connectionWindow = 1024 * 1024) + { + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); + sm.PreStart(); + + var settingsFrame = BuildSettingsFrameWithMaxFrameSize(clientMaxFrameSize); + DecodeFramesAsStream(sm, settingsFrame); + + if (connectionWindow > 65535) + { + var connWindowUpdate = BuildWindowUpdateFrame(0, (uint)(connectionWindow - 65535)); + DecodeFramesAsStream(sm, connWindowUpdate); + } + + ops.Outbound.Clear(); + return sm; + } + + private static IFeatureCollection SendGetAndWriteBufferedBody( + Http2ServerStateMachine sm, FakeServerOps ops, int streamId, int bodySize) + { + var headerBlock = EncodeHeaders("GET", "/large", "example.com"); + var headersFrameData = BuildHeadersFrame(streamId, headerBlock, endStream: true, endHeaders: true); + DecodeFramesAsStream(sm, headersFrameData); + + var features = ops.Requests[^1]; + var responseFeature = features.Get()!; + responseFeature.StatusCode = 200; + responseFeature.Headers["Content-Length"] = bodySize.ToString(); + + var bodyFeature = features.Get()!; + var body = new byte[bodySize]; + for (var i = 0; i < body.Length; i++) + { + body[i] = (byte)(i % 251); + } + + var writer = bodyFeature.Writer; + var mem = writer.GetMemory(bodySize); + body.CopyTo(mem); + writer.Advance(bodySize); + + return features; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-4.2")] + public void OnResponse_buffered_body_should_split_frames_by_max_frame_size() + { + var ops = new FakeServerOps(); + const uint clientMaxFrameSize = 16 * 1024; + const int bodySize = 48 * 1024; + var sm = CreateSmWithClientMaxFrameSize(ops, clientMaxFrameSize, connectionWindow: bodySize + 65535); + + var features = SendGetAndWriteBufferedBody(sm, ops, streamId: 1, bodySize); + var streamWindowUpdate = BuildWindowUpdateFrame(1, (uint)bodySize); + DecodeFramesAsStream(sm, streamWindowUpdate); + + ops.Outbound.Clear(); + sm.OnResponse(features); + + var frames = ExtractFrames(ops.Outbound); + var dataFrames = frames.OfType().ToList(); + + Assert.True(dataFrames.Count >= 3, $"Expected at least 3 DATA frames for {bodySize} bytes at {clientMaxFrameSize} max frame size, got {dataFrames.Count}"); + + foreach (var df in dataFrames) + { + Assert.True(df.Data.Length <= (int)clientMaxFrameSize, + $"DATA frame payload {df.Data.Length} exceeds client MAX_FRAME_SIZE {clientMaxFrameSize}"); + } + + var totalDataBytes = dataFrames.Sum(df => df.Data.Length); + Assert.Equal(bodySize, totalDataBytes); + + Assert.True(dataFrames[^1].EndStream); + for (var i = 0; i < dataFrames.Count - 1; i++) + { + Assert.False(dataFrames[i].EndStream); + } + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-4.2")] + public void OnResponse_buffered_body_should_respect_custom_client_max_frame_size() + { + var ops = new FakeServerOps(); + const uint clientMaxFrameSize = 32 * 1024; + const int bodySize = 96 * 1024; + var sm = CreateSmWithClientMaxFrameSize(ops, clientMaxFrameSize, connectionWindow: bodySize + 65535); + + var features = SendGetAndWriteBufferedBody(sm, ops, streamId: 1, bodySize); + var streamWindowUpdate = BuildWindowUpdateFrame(1, (uint)bodySize); + DecodeFramesAsStream(sm, streamWindowUpdate); + + ops.Outbound.Clear(); + sm.OnResponse(features); + + var frames = ExtractFrames(ops.Outbound); + var dataFrames = frames.OfType().ToList(); + + foreach (var df in dataFrames) + { + Assert.True(df.Data.Length <= (int)clientMaxFrameSize, + $"DATA frame payload {df.Data.Length} exceeds client MAX_FRAME_SIZE {clientMaxFrameSize}"); + } + + var totalDataBytes = dataFrames.Sum(df => df.Data.Length); + Assert.Equal(bodySize, totalDataBytes); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9113-6.9")] + public void DrainOutboundBuffer_should_partial_send_when_window_is_smaller_than_chunk() + { + var ops = new FakeServerOps(); + const uint clientMaxFrameSize = 16 * 1024; + const int bodySize = 48 * 1024; + var sm = CreateSmWithClientMaxFrameSize(ops, clientMaxFrameSize, connectionWindow: bodySize + 65535); + + var features = SendGetAndWriteBufferedBody(sm, ops, streamId: 1, bodySize); + + ops.Outbound.Clear(); + sm.OnResponse(features); + + var framesBeforeWindowUpdate = ExtractFrames(ops.Outbound); + var dataBeforeWindowUpdate = framesBeforeWindowUpdate.OfType().ToList(); + + var totalSentBefore = dataBeforeWindowUpdate.Sum(df => df.Data.Length); + Assert.True(totalSentBefore <= 65535, "Should not exceed initial send window of 65535"); + Assert.True(totalSentBefore > 0, "Should send at least some data within the initial window"); + + ops.Outbound.Clear(); + var windowUpdate = BuildWindowUpdateFrame(1, (uint)bodySize); + DecodeFramesAsStream(sm, windowUpdate); + + var framesAfterWindowUpdate = ExtractFrames(ops.Outbound); + var dataAfterWindowUpdate = framesAfterWindowUpdate.OfType().ToList(); + var totalSentAfter = dataAfterWindowUpdate.Sum(df => df.Data.Length); + + Assert.Equal(bodySize, totalSentBefore + totalSentAfter); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerTimeoutSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerTimeoutSpec.cs index 6593afdf9..cf286eed3 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerTimeoutSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerTimeoutSpec.cs @@ -48,7 +48,7 @@ private static byte[] BuildDataFrame(int streamId, byte[] data, bool endStream = frame[1] = (byte)(length >> 8); frame[2] = (byte)length; frame[3] = (byte)FrameType.Data; - frame[4] = endStream ? (byte)DataFlags.EndStream : (byte)0; + frame[4] = endStream ? (byte)Datas.EndStream : (byte)0; frame[5] = (byte)(streamId >> 24); frame[6] = (byte)(streamId >> 16); @@ -90,7 +90,7 @@ public void PreStart_should_schedule_keep_alive_timeout() KeepAliveTimeout = TimeSpan.FromSeconds(130) } }; - var sm = new Http2ServerStateMachine(options, ops); + var sm = new Http2ServerStateMachine(options.ToHttp2Options(), ops); sm.PreStart(); @@ -106,7 +106,7 @@ public void PreStart_should_schedule_keep_alive_timeout() public void KeepAlive_timeout_should_emit_goaway() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.Outbound.Clear(); @@ -127,7 +127,7 @@ public void KeepAlive_timeout_should_emit_goaway() public void KeepAlive_should_cancel_on_stream_open() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); ops.CancelledTimers.Clear(); @@ -141,7 +141,7 @@ public void KeepAlive_should_cancel_on_stream_open() headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Keep-alive should be cancelled Assert.Contains("keep-alive-timeout", ops.CancelledTimers); @@ -159,7 +159,7 @@ public void Headers_timeout_should_rst_stream_on_continuation_timeout() RequestHeadersTimeout = TimeSpan.FromSeconds(30) } }; - var sm = new Http2ServerStateMachine(options, ops); + var sm = new Http2ServerStateMachine(options.ToHttp2Options(), ops); sm.PreStart(); @@ -176,7 +176,7 @@ public void Headers_timeout_should_rst_stream_on_continuation_timeout() headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Headers timeout should be scheduled var headersTimer = ops.ScheduledTimers.FirstOrDefault(t => t.Name.StartsWith("headers-timeout:")); @@ -209,7 +209,7 @@ public void Headers_timeout_should_cancel_on_endheaders() RequestHeadersTimeout = TimeSpan.FromSeconds(30) } }; - var sm = new Http2ServerStateMachine(options, ops); + var sm = new Http2ServerStateMachine(options.ToHttp2Options(), ops); sm.PreStart(); @@ -226,7 +226,7 @@ public void Headers_timeout_should_cancel_on_endheaders() headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); ops.CancelledTimers.Clear(); @@ -236,7 +236,7 @@ public void Headers_timeout_should_cancel_on_endheaders() continuationData.CopyTo(buffer.FullMemory.Span); buffer.Length = continuationData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); // Headers timeout should be cancelled Assert.Contains("headers-timeout:1", ops.CancelledTimers); @@ -253,7 +253,7 @@ private static byte[] BuildContinuationFrame(int streamId, ReadOnlyMemory frame[1] = (byte)(length >> 8); frame[2] = (byte)length; frame[3] = (byte)FrameType.Continuation; - frame[4] = endHeaders ? (byte)ContinuationFlags.EndHeaders : (byte)0; + frame[4] = endHeaders ? (byte)Continuations.EndHeaders : (byte)0; frame[5] = (byte)(streamId >> 24); frame[6] = (byte)(streamId >> 16); @@ -267,10 +267,10 @@ private static byte[] BuildContinuationFrame(int streamId, ReadOnlyMemory [Fact(Timeout = 5000)] [Trait("RFC", "RFC9113-5.4")] - public void Body_rate_check_should_schedule_on_data_frame() + public void Data_rate_check_should_schedule_on_request_data_frame() { var ops = new FakeServerOps(); - var sm = new Http2ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops); sm.PreStart(); @@ -282,7 +282,7 @@ public void Body_rate_check_should_schedule_on_data_frame() headersFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = headersFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); ops.ScheduledTimers.Clear(); @@ -294,12 +294,12 @@ public void Body_rate_check_should_schedule_on_data_frame() dataFrameData.CopyTo(buffer.FullMemory.Span); buffer.Length = dataFrameData.Length; - sm.DecodeClientData(new TransportData(buffer)); + sm.DecodeClientData(TransportData.Rent(buffer)); - // Body rate check timer should be scheduled - var rateTimer = ops.ScheduledTimers.FirstOrDefault(t => t.Name == "body-rate-check"); + // Data rate check timer should be scheduled + var rateTimer = ops.ScheduledTimers.FirstOrDefault(t => t.Name == "data-rate-check"); Assert.NotNull(rateTimer.Name); - Assert.Equal("body-rate-check", rateTimer.Name); + Assert.Equal("data-rate-check", rateTimer.Name); Assert.Equal(TimeSpan.FromSeconds(1), rateTimer.Delay); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs index 28dee3972..071d7a981 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs @@ -218,7 +218,7 @@ public async Task Http20ConnectionStage_should_handle_settings_frame() resSubscription.Request(10); // Server sends SETTINGS frame before any client request - serverSubscription.SendNext(new TransportData(MakeResponseBuffer("\x00\x00\x00\x04\x00\x00\x00\x00\x00"))); + serverSubscription.SendNext(TransportData.Rent(MakeResponseBuffer("\x00\x00\x00\x04\x00\x00\x00\x00\x00"))); Assert.True(true); } @@ -263,7 +263,7 @@ public async Task Http20ConnectionStage_should_complete_on_goaway_with_no_inflig serverSubscription.SendComplete(); // Stage completes when server upstream finishes - networkSub.ExpectComplete(); + networkSub.ExpectComplete(TestContext.Current.CancellationToken); } [Fact(Timeout = 10_000)] @@ -305,6 +305,6 @@ public async Task Http20ConnectionStage_should_complete_when_app_upstream_finish appSubscription.SendComplete(); // Stage should complete - responseSub.ExpectComplete(); + responseSub.ExpectComplete(TestContext.Current.CancellationToken); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionFlowControlBatchingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionFlowControlBatchingSpec.cs index 0b1cb6c90..4ae65152c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionFlowControlBatchingSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionFlowControlBatchingSpec.cs @@ -59,7 +59,7 @@ public sealed class Http2ConnectionFlowControlBatchingSpec : StreamTestBase [Trait("RFC", "RFC9113-6.9")] public void Http2Engine_should_have_64_mib_initial_connection_window_when_default_options_used() { - var options = new Http2Options(); + var options = new Http2ClientOptions(); Assert.Equal(64 * 1024 * 1024, options.InitialConnectionWindowSize); } @@ -88,7 +88,7 @@ public async Task public async Task Http2ConnectionFlowControlBatching_should_send_both_window_updates_when_threshold_crossed_in_single_frame() { - // 40000 bytes crosses both connection and stream threshold (32767) at once. + // 40000 bytes crosses both connection and stream threshold (65535/4 = 16383) at once. var data = new DataFrame(streamId: 1, data: new byte[40000], endStream: true); var (_, serverBound) = await RunAsync(65535, data); @@ -105,9 +105,9 @@ public async Task public async Task Http2ConnectionFlowControlBatching_should_send_single_batched_window_update_when_multiple_frames_accumulate_to_threshold() { - // Two 20000-byte frames accumulate to 40000 → threshold (32767) crossed on second frame. - var frame1 = new DataFrame(streamId: 1, data: new byte[20000], endStream: false); - var frame2 = new DataFrame(streamId: 1, data: new byte[20000], endStream: true); + // Two 10000-byte frames accumulate to 20000 → threshold (65535/4 = 16383) crossed on second frame. + var frame1 = new DataFrame(streamId: 1, data: new byte[10000], endStream: false); + var frame2 = new DataFrame(streamId: 1, data: new byte[10000], endStream: true); var (_, serverBound) = await RunAsync(65535, frame1, frame2); @@ -120,11 +120,11 @@ public async Task // Exactly one connection-level WINDOW_UPDATE with the full batched increment var connUpdate = Assert.Single(connectionUpdates); - Assert.Equal(40000, connUpdate.Increment); + Assert.Equal(20000, connUpdate.Increment); // Exactly one stream-level WINDOW_UPDATE (threshold flush; stream close pending = 0) var streamUpdate = Assert.Single(streamUpdates); - Assert.Equal(40000, streamUpdate.Increment); + Assert.Equal(20000, streamUpdate.Increment); } [Fact(Timeout = 5_000)] @@ -132,7 +132,7 @@ public async Task public async Task Http2ConnectionFlowControlBatching_should_batch_streams_independently_when_two_streams_send_data_below_threshold() { - // Stream 1: 40000 bytes → hits threshold (32767) → stream WU(1) sent. + // Stream 1: 40000 bytes → hits threshold (65535/4 = 16383) → stream WU(1) sent. // Stream 3: 8192 bytes → below threshold → stream WU(3) flushed only at close. var s1 = new DataFrame(streamId: 1, data: new byte[40000], endStream: true); var s3 = new DataFrame(streamId: 3, data: new byte[8192], endStream: true); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionTestHelper.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionTestHelper.cs index cf20e520c..ef2ba769e 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionTestHelper.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http2ConnectionTestHelper.cs @@ -21,7 +21,7 @@ public static ITransportInbound FramesToInput(params Http2Frame[] frames) } buf.Length = totalSize; - return new TransportData(buf); + return TransportData.Rent(buf); } public static IEnumerable FramesToInputs(IEnumerable frames) diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/WindowScalerSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/WindowScalerSpec.cs new file mode 100644 index 000000000..0602effa4 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/WindowScalerSpec.cs @@ -0,0 +1,81 @@ +using TurboHTTP.Protocol.Syntax.Http2; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http2; + +public sealed class WindowScalerSpec +{ + private const int Start = 64 * 1024; + private const int Cap = 16 * 1024 * 1024; + + [Fact(Timeout = 5000)] + public void WindowScaler_should_double_window_when_saturated_at_low_rtt() + { + var scaler = new WindowScaler(Cap, multiplier: 1.0); + + var result = scaler.ComputeNewWindow(Start, 1 * 1024 * 1024, + TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); + + Assert.Equal(Start * 2, result); + } + + [Fact(Timeout = 5000)] + public void WindowScaler_should_not_grow_when_throughput_below_window() + { + var scaler = new WindowScaler(Cap, multiplier: 1.0); + + var result = scaler.ComputeNewWindow(Start, 1024, + TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(1)); + + Assert.Equal(Start, result); + } + + [Fact(Timeout = 5000)] + public void WindowScaler_should_not_grow_when_min_rtt_unknown() + { + var scaler = new WindowScaler(Cap, multiplier: 1.0); + + var result = scaler.ComputeNewWindow(Start, 8 * 1024 * 1024, + TimeSpan.FromMilliseconds(100), TimeSpan.Zero); + + Assert.Equal(Start, result); + } + + [Fact(Timeout = 5000)] + public void WindowScaler_should_cap_growth_at_max_window() + { + var scaler = new WindowScaler(Cap, multiplier: 1.0); + var nearCap = Cap - 1024; + + var result = scaler.ComputeNewWindow(nearCap, 64 * 1024 * 1024, + TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); + + Assert.Equal(Cap, result); + } + + [Fact(Timeout = 5000)] + public void WindowScaler_should_not_grow_when_already_at_cap() + { + var scaler = new WindowScaler(Cap, multiplier: 1.0); + + var result = scaler.ComputeNewWindow(Cap, 64 * 1024 * 1024, + TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); + + Assert.Equal(Cap, result); + } + + [Fact(Timeout = 5000)] + public void WindowScaler_should_grow_less_eagerly_with_higher_multiplier() + { + var eager = new WindowScaler(Cap, multiplier: 1.0); + var lazy = new WindowScaler(Cap, multiplier: 16.0); + + var delivered = (long)(Start * 2); + var grewEager = eager.ComputeNewWindow(Start, delivered, + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(10)); + var grewLazy = lazy.ComputeNewWindow(Start, delivered, + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(10)); + + Assert.Equal(Start * 2, grewEager); + Assert.Equal(Start, grewLazy); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientBodyBackpressureSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientBodyBackpressureSpec.cs new file mode 100644 index 000000000..03b39b436 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientBodyBackpressureSpec.cs @@ -0,0 +1,89 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http3; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; + +public sealed class Http3ClientBodyBackpressureSpec +{ + private static StreamBodyChunk Chunk(int len) + { + var owner = MemoryPool.Shared.Rent(len); + return new StreamBodyChunk(owner, len); + } + + [Fact(Timeout = 5000)] + public void Enqueue_should_accumulate_pending_bytes() + { + var state = new StreamState(); + + state.EnqueueBodyChunk(Chunk(48 * 1024)); + Assert.Equal(48 * 1024, state.PendingOutboundBytes); + + state.EnqueueBodyChunk(Chunk(48 * 1024)); + Assert.Equal(96 * 1024, state.PendingOutboundBytes); + } + + [Fact(Timeout = 5000)] + public void Dequeue_should_reduce_pending_bytes() + { + var state = new StreamState(); + + state.EnqueueBodyChunk(Chunk(48 * 1024)); + state.EnqueueBodyChunk(Chunk(48 * 1024)); + + state.TryDequeueBodyChunk(out var c1); + c1!.Owner.Dispose(); + + state.TryDequeueBodyChunk(out var c2); + c2!.Owner.Dispose(); + + Assert.Equal(0, state.PendingOutboundBytes); + } + + [Fact(Timeout = 5000)] + public void MarkBodyDrainActive_should_set_drain_flags() + { + var state = new StreamState(); + + state.MarkBodyDrainActive(); + + Assert.True(state.HasBodyDrain); + Assert.False(state.IsBodyDrainComplete); + } + + [Fact(Timeout = 5000)] + public void MarkBodyDrainComplete_should_set_complete_flag() + { + var state = new StreamState(); + + state.MarkBodyDrainActive(); + state.MarkBodyDrainComplete(); + + Assert.True(state.IsBodyDrainComplete); + } + + [Fact(Timeout = 5000)] + public void InitBodyReader_should_set_HasBodyReader() + { + var state = new StreamState(); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + + state.InitBodyReader(reader); + + Assert.True(state.HasBodyReader); + } + + [Fact(Timeout = 5000)] + public void FeedBody_should_reject_when_exceeding_max_size() + { + var state = new StreamState(); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + state.InitBodyReader(reader, maxBodySize: 10); + + var data = new byte[11]; + Assert.Throws(() => state.FeedBody(data, endStream: false)); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientBodyFastPathSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientBodyFastPathSpec.cs new file mode 100644 index 000000000..fc7176354 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientBodyFastPathSpec.cs @@ -0,0 +1,245 @@ +using Servus.Akka.Transport; +using TurboHTTP.Client; +using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Client; +using TurboHTTP.Tests.Shared; +using TurboHTTP.Tests.TestSupport; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; + +public sealed class Http3ClientBodyFastPathSpec +{ + // A custom HttpContent whose ReadAsStream() returns a publicly-visible MemoryStream, + // exactly the pattern the fast path is designed for. + private sealed class VisibleMemoryStreamContent : HttpContent + { + private readonly MemoryStream _ms; + + public VisibleMemoryStreamContent(byte[] body) + { + _ms = new MemoryStream(); + _ms.Write(body); + Headers.ContentLength = body.Length; + } + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => + _ms.CopyToAsync(stream); + + protected override bool TryComputeLength(out long length) + { + length = _ms.Length; + return true; + } + + protected override Stream CreateContentReadStream(CancellationToken cancellationToken) + { + _ms.Position = 0; + return _ms; + } + } + + private static Http3ClientSessionManager CreateSession(FakeClientOps ops) + { + var encoderOpts = ClientOptionDefaults.Http3Encoder(); + var decoderOpts = ClientOptionDefaults.Http3Decoder(); + var clientOpts = new TurboClientOptions { DangerousAcceptAnyServerCertificate = true }; + var sm = new Http3ClientSessionManager(encoderOpts, decoderOpts, clientOpts, ops); + sm.OnTransportConnected(); + return sm; + } + + private static List DecodeOutboundData(FakeClientOps ops, long streamId) + { + var decoder = new FrameDecoder(); + var frames = new List(); + foreach (var item in ops.Outbound) + { + if (item is MultiplexedData md && md.StreamId == streamId) + { + // ToList so the reused decoder buffer is copied before the next decode call + frames.AddRange(decoder.DecodeAll(md.Buffer.Memory.Span, out _).ToList()); + md.Buffer.Dispose(); + } + } + + return frames; + } + + private static HttpRequestMessage BuildPost(byte[] body) + { + return new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new VisibleMemoryStreamContent(body) + }; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void Visible_MemoryStream_body_should_emit_single_DATA_frame() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[256]; + new Random(42).NextBytes(body); + var request = BuildPost(body); + + sm.EncodeRequest(request); + + // Stream 0 is the first bidirectional request stream + var frames = DecodeOutboundData(ops, streamId: 0); + var dataFrames = frames.OfType().ToList(); + + Assert.Single(dataFrames); + Assert.Equal(body, dataFrames[0].Data.ToArray()); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void Fast_path_should_emit_CompleteWrites_after_DATA_frame() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[64]; + new Random(1).NextBytes(body); + var request = BuildPost(body); + + sm.EncodeRequest(request); + + var completeWrites = ops.Outbound.OfType().ToList(); + Assert.NotEmpty(completeWrites); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void Fast_path_should_preserve_all_bytes_for_large_payload() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + // 1 MiB — verifies no truncation + var body = new byte[1 * 1024 * 1024]; + new Random(55).NextBytes(body); + var request = BuildPost(body); + + sm.EncodeRequest(request); + + var frames = DecodeOutboundData(ops, streamId: 0); + var dataFrames = frames.OfType().ToList(); + + var assembled = dataFrames.SelectMany(f => f.Data.ToArray()).ToArray(); + Assert.Equal(body, assembled); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void Non_visible_MemoryStream_should_fall_through_to_encoder_slow_path() + { + // ByteArrayContent.ReadAsStream() returns MemoryStream with TryGetBuffer=false. + // Verify EncodeRequest does not throw and falls back to the encoder. + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) + }; + + // Should not throw — falls back to encoder + var exception = Record.Exception(() => sm.EncodeRequest(request)); + Assert.Null(exception); + } + + // A custom HttpContent that overrides SerializeToStream synchronously (fast path B target). + // Its ReadAsStream() returns a non-visible MemoryStream so the TryGetBuffer fast path A + // does not trigger, exercising the SerializeToStream code path instead. + private sealed class SyncSerializableContent : HttpContent + { + private readonly byte[] _body; + + public SyncSerializableContent(byte[] body) + { + _body = body; + Headers.ContentLength = body.Length; + } + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => + stream.WriteAsync(_body).AsTask(); + + protected override void SerializeToStream(Stream stream, System.Net.TransportContext? context, CancellationToken cancellationToken) => + stream.Write(_body); + + protected override bool TryComputeLength(out long length) + { + length = _body.Length; + return true; + } + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void SerializeToStream_fast_path_should_emit_single_DATA_frame_for_sync_content() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[200]; + new Random(77).NextBytes(body); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new SyncSerializableContent(body) + }; + + sm.EncodeRequest(request); + + var frames = DecodeOutboundData(ops, streamId: 0); + var dataFrames = frames.OfType().ToList(); + + Assert.Single(dataFrames); + Assert.Equal(body, dataFrames[0].Data.ToArray()); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void SerializeToStream_fast_path_should_emit_CompleteWrites_after_DATA_frame() + { + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[64]; + new Random(3).NextBytes(body); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new SyncSerializableContent(body) + }; + + sm.EncodeRequest(request); + + var completeWrites = ops.Outbound.OfType().ToList(); + Assert.NotEmpty(completeWrites); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void SerializeToStream_fast_path_should_skip_body_exceeding_buffer_threshold() + { + // Body above MaxBufferedRequestBodySize (default 64 KiB) must bypass the fast path + // and be handed off to the async encoder without throwing. + var ops = new FakeClientOps(); + var sm = CreateSession(ops); + + var body = new byte[128 * 1024]; + new Random(5).NextBytes(body); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") + { + Content = new SyncSerializableContent(body) + }; + + var exception = Record.Exception(() => sm.EncodeRequest(request)); + Assert.Null(exception); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3OptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientOptionsSpec.cs similarity index 92% rename from src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3OptionsSpec.cs rename to src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientOptionsSpec.cs index 61a2480b3..a897978c5 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3OptionsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ClientOptionsSpec.cs @@ -2,13 +2,13 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; -public sealed class Http3OptionsSpec +public sealed class Http3ClientOptionsSpec { [Fact(Timeout = 5000)] [Trait("RFC", "RFC9114-7.2.4.1")] public void Http3Options_should_have_correct_defaults() { - var options = new Http3Options(); + var options = new Http3ClientOptions(); Assert.Equal(4, options.MaxConnectionsPerServer); Assert.Equal(16_384, options.QpackMaxTableCapacity); @@ -22,7 +22,7 @@ public void Http3Options_should_have_correct_defaults() [Trait("RFC", "RFC9114-7.2.4.1")] public void Http3Options_should_allow_custom_values() { - var options = new Http3Options + var options = new Http3ClientOptions { MaxConnectionsPerServer = 8, QpackMaxTableCapacity = 8192, diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ConnectEncodingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ConnectEncodingSpec.cs index cbe428ed1..333b5ff21 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ConnectEncodingSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ConnectEncodingSpec.cs @@ -10,7 +10,7 @@ public sealed class Http3ConnectEncodingSpec { var frames = encoder.Encode(request); var headersFrame = (HeadersFrame)frames[0]; - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(0, 100); return decoder.Decode(headersFrame.HeaderBlock.Span); } @@ -18,7 +18,7 @@ public sealed class Http3ConnectEncodingSpec [Trait("RFC", "RFC9114-4.4")] public void Encode_should_omit_scheme_when_connect() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Connect, "https://proxy.example.com:443/"); var headers = DecodeHeaders(encoder, request); Assert.DoesNotContain(headers, h => h.Name == ":scheme"); @@ -28,7 +28,7 @@ public void Encode_should_omit_scheme_when_connect() [Trait("RFC", "RFC9114-4.4")] public void Encode_should_omit_path_when_connect() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Connect, "https://proxy.example.com:443/"); var headers = DecodeHeaders(encoder, request); Assert.DoesNotContain(headers, h => h.Name == ":path"); @@ -38,7 +38,7 @@ public void Encode_should_omit_path_when_connect() [Trait("RFC", "RFC9114-4.4")] public void Encode_should_include_authority_with_port_when_connect() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Connect, "https://proxy.example.com:8443/"); var headers = DecodeHeaders(encoder, request); Assert.Contains(headers, h => h.Name == ":authority" && h.Value.Contains("8443")); @@ -48,7 +48,7 @@ public void Encode_should_include_authority_with_port_when_connect() [Trait("RFC", "RFC9114-4.4")] public void Encode_should_include_method_connect_when_connect() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Connect, "https://proxy.example.com:443/"); var headers = DecodeHeaders(encoder, request); Assert.Contains(headers, h => h is { Name: ":method", Value: "CONNECT" }); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3CookieHeaderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3CookieHeaderSpec.cs index d101a5b05..20db193fa 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3CookieHeaderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3CookieHeaderSpec.cs @@ -18,7 +18,7 @@ public void QpackEncoder_should_encode_cookie_headers_independently() ("cookie", "b=2"), }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(0, 100); var decoded = decoder.Decode(encoded.Span); var cookieHeaders = decoded.Where(h => h.Name == "cookie").ToList(); Assert.Equal(2, cookieHeaders.Count); @@ -30,8 +30,8 @@ public void QpackEncoder_should_encode_cookie_headers_independently() [Trait("RFC", "RFC9114-4.2")] public void ResponseDecoder_should_accept_single_cookie_header() { - var tableSync = new QpackTableSync(); - var decoder = new Http3ClientDecoder(tableSync); + var tableSync = new QpackTableSync(0, 4096, 100, null); + var decoder = new Http3ClientDecoder(tableSync, int.MaxValue); var frame = new HeadersFrame(tableSync.Encoder.Encode([ (":status", "200"), ("cookie", "session=abc123") @@ -45,8 +45,8 @@ public void ResponseDecoder_should_accept_single_cookie_header() [Trait("RFC", "RFC9114-4.2")] public void ResponseDecoder_should_accept_multiple_cookie_headers() { - var tableSync = new QpackTableSync(); - var decoder = new Http3ClientDecoder(tableSync); + var tableSync = new QpackTableSync(0, 4096, 100, null); + var decoder = new Http3ClientDecoder(tableSync, int.MaxValue); var frame = new HeadersFrame(tableSync.Encoder.Encode([ (":status", "200"), ("cookie", "a=1"), diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FieldSectionSizeSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FieldSectionSizeSpec.cs index 95528a6f9..593c78fe9 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FieldSectionSizeSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FieldSectionSizeSpec.cs @@ -10,7 +10,7 @@ public sealed class Http3FieldSectionSizeSpec [Trait("RFC", "RFC9114-4.2.2")] public void ResponseDecoder_should_reject_oversized_field_section() { - var tableSync = new QpackTableSync(encoderMaxCapacity: 0); + var tableSync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var decoder = new Http3ClientDecoder(tableSync, maxFieldSectionSize: 64); var longValue = new string('x', 100); @@ -27,7 +27,7 @@ public void ResponseDecoder_should_reject_oversized_field_section() [Trait("RFC", "RFC9114-4.2.2")] public void ResponseDecoder_should_accept_field_section_within_limit() { - var tableSync = new QpackTableSync(encoderMaxCapacity: 0); + var tableSync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var decoder = new Http3ClientDecoder(tableSync, maxFieldSectionSize: 65536); var headerFrame = new HeadersFrame( @@ -43,7 +43,7 @@ public void ResponseDecoder_should_accept_field_section_within_limit() [Trait("RFC", "RFC9114-4.2.2")] public void RequestEncoder_should_reject_headers_exceeding_peer_limit() { - var tableSync = new QpackTableSync(encoderMaxCapacity: 0) + var tableSync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null) { RemoteMaxFieldSectionSize = 32 }; @@ -59,7 +59,7 @@ public void RequestEncoder_should_reject_headers_exceeding_peer_limit() [Trait("RFC", "RFC9114-4.2.2")] public void RequestEncoder_should_allow_headers_within_peer_limit() { - var tableSync = new QpackTableSync(encoderMaxCapacity: 0) + var tableSync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null) { RemoteMaxFieldSectionSize = 65536 }; @@ -76,7 +76,7 @@ public void RequestEncoder_should_allow_headers_within_peer_limit() [Trait("RFC", "RFC9114-4.2.2")] public void RequestEncoder_should_skip_check_when_no_peer_limit() { - var tableSync = new QpackTableSync(encoderMaxCapacity: 0); + var tableSync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var encoder = new Http3ClientEncoder(tableSync); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FrameBatchingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FrameBatchingSpec.cs index 1f755cac7..a4fcd16eb 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FrameBatchingSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3FrameBatchingSpec.cs @@ -1,8 +1,8 @@ using Servus.Akka.Transport; using TurboHTTP.Client; using TurboHTTP.Protocol.Syntax.Http3.Client; -using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Tests.Shared; +using TurboHTTP.Tests.TestSupport; namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; @@ -11,9 +11,9 @@ public sealed class Http3FrameBatchingSpec [Fact(Timeout = 5000)] public void EncodeRequest_should_emit_single_MultiplexedData_for_headeronly_request() { - var ops = new FakeOps(); - var encoderOpts = Http3ClientEncoderOptions.Default; - var decoderOpts = Http3ClientDecoderOptions.Default; + var ops = new FakeClientOps(); + var encoderOpts = ClientOptionDefaults.Http3Encoder(); + var decoderOpts = ClientOptionDefaults.Http3Decoder(); var clientOpts = new TurboClientOptions { DangerousAcceptAnyServerCertificate = true }; var session = new Http3ClientSessionManager(encoderOpts, decoderOpts, clientOpts, ops); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3PseudoHeaderValidationRequestSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3PseudoHeaderValidationRequestSpec.cs index 0e1c70d90..a6785441a 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3PseudoHeaderValidationRequestSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3PseudoHeaderValidationRequestSpec.cs @@ -256,8 +256,8 @@ public void Request_duplicate_authority_rejected() [Trait("RFC", "RFC9114-4.3.1")] public void Encoder_generates_valid_pseudo_headers_for_get() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/path?q=1"); var frames = encoder.Encode(request); @@ -274,8 +274,8 @@ public void Encoder_generates_valid_pseudo_headers_for_get() [Trait("RFC", "RFC9114-4.3.1")] public void Encoder_generates_valid_pseudo_headers_for_post() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com:8443/submit"); var frames = encoder.Encode(request); @@ -292,8 +292,8 @@ public void Encoder_generates_valid_pseudo_headers_for_post() [Trait("RFC", "RFC9114-4.3.1")] public void Encoder_pseudo_headers_before_regular() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("accept", "text/html"); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderAdvancedSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderAdvancedSpec.cs index f1624214c..8c1f10ef1 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderAdvancedSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderAdvancedSpec.cs @@ -11,8 +11,8 @@ public sealed class Http3RequestEncoderAdvancedSpec [Trait("RFC", "RFC9114-4.1")] public void Custom_headers_included() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("accept", "application/json"); request.Headers.TryAddWithoutValidation("x-request-id", "abc-123"); @@ -29,8 +29,8 @@ public void Custom_headers_included() [Trait("RFC", "RFC9114-4.1")] public void Content_headers_included() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/api") { Content = new StringContent("{}", Encoding.UTF8, "application/json"), @@ -47,8 +47,8 @@ public void Content_headers_included() [Trait("RFC", "RFC9114-4.1")] public void Header_names_lowercased() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("Accept-Language", "en-US"); @@ -70,8 +70,8 @@ public void Header_names_lowercased() [InlineData("keep-alive")] public void Forbidden_headers_filtered(string forbiddenHeader) { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation(forbiddenHeader, "some-value"); @@ -86,8 +86,8 @@ public void Forbidden_headers_filtered(string forbiddenHeader) [Trait("RFC", "RFC9114-4.2")] public void Non_forbidden_headers_preserved() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("connection", "keep-alive"); request.Headers.TryAddWithoutValidation("accept", "*/*"); @@ -104,7 +104,7 @@ public void Non_forbidden_headers_preserved() [Trait("RFC", "RFC9114-4.3.1")] public void Null_request_throws() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); Assert.Throws(() => encoder.Encode(null!)); } @@ -112,7 +112,7 @@ public void Null_request_throws() [Trait("RFC", "RFC9114-4.3.1")] public void Null_uri_throws() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, (Uri?)null); Assert.Throws(() => encoder.Encode(request)); } @@ -186,7 +186,7 @@ public void Validate_rejects_pseudo_after_regular() [Trait("RFC", "RFC9114-4.1")] public void Encoder_is_stateful_across_requests() { - var encoder = new Http3ClientEncoder(new QpackTableSync(encoderMaxCapacity: 4096)); + var encoder = new Http3ClientEncoder(new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null)); var request1 = new HttpRequestMessage(HttpMethod.Get, "https://example.com/page1"); var frames1 = encoder.Encode(request1); @@ -209,7 +209,7 @@ public void Encoder_is_stateful_across_requests() [Trait("RFC", "RFC9114-4.1")] public void Large_body_request_produces_headers_only() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var body = new byte[64 * 1024]; // 64 KB new Random(42).NextBytes(body); var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/upload") @@ -227,8 +227,8 @@ public void Large_body_request_produces_headers_only() [Trait("RFC", "RFC9114-4.3.1")] public void Root_path_encoded() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); var frames = encoder.Encode(request); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderBasicSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderBasicSpec.cs index 88a59d665..326a2f27e 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderBasicSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderBasicSpec.cs @@ -11,7 +11,7 @@ public sealed class Http3RequestEncoderBasicSpec [Trait("RFC", "RFC9114-4.1")] public void Get_request_produces_single_headers_frame() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); var frames = encoder.Encode(request); @@ -24,7 +24,7 @@ public void Get_request_produces_single_headers_frame() [Trait("RFC", "RFC9114-4.1")] public void Post_with_body_produces_headers_only() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/api") { Content = new StringContent("payload", Encoding.UTF8, "text/plain"), @@ -40,7 +40,7 @@ public void Post_with_body_produces_headers_only() [Trait("RFC", "RFC9114-4.1")] public void Post_with_empty_body_produces_headers_only() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/api") { Content = new ByteArrayContent([]), @@ -56,7 +56,7 @@ public void Post_with_empty_body_produces_headers_only() [Trait("RFC", "RFC9114-4.1")] public void Put_with_body_produces_headers_only() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var body = "Hello, HTTP/3!"u8.ToArray(); var request = new HttpRequestMessage(HttpMethod.Put, "https://example.com/resource") { @@ -73,7 +73,7 @@ public void Put_with_body_produces_headers_only() [Trait("RFC", "RFC9114-4.1")] public void Delete_without_body_produces_single_headers() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Delete, "https://example.com/item/42"); var frames = encoder.Encode(request); @@ -87,8 +87,8 @@ public void Delete_without_body_produces_single_headers() [Trait("RFC", "RFC9114-4.3.1")] public void All_four_pseudo_headers_present() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/path"); var frames = encoder.Encode(request); @@ -112,8 +112,8 @@ public void All_four_pseudo_headers_present() [InlineData("OPTIONS")] public void Method_pseudo_header_reflects_http_method(string method) { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(new HttpMethod(method), "https://example.com/"); var frames = encoder.Encode(request); @@ -127,8 +127,8 @@ public void Method_pseudo_header_reflects_http_method(string method) [Trait("RFC", "RFC9114-4.3.1")] public void Path_includes_query_string() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/search?q=test&page=2"); var frames = encoder.Encode(request); @@ -142,8 +142,8 @@ public void Path_includes_query_string() [Trait("RFC", "RFC9114-4.3.1")] public void Path_without_query_string() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); var frames = encoder.Encode(request); @@ -157,8 +157,8 @@ public void Path_without_query_string() [Trait("RFC", "RFC9114-4.3.1")] public void Scheme_is_https() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); var frames = encoder.Encode(request); @@ -172,8 +172,8 @@ public void Scheme_is_https() [Trait("RFC", "RFC9114-4.3.1")] public void Scheme_is_http() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/"); var frames = encoder.Encode(request); @@ -187,8 +187,8 @@ public void Scheme_is_http() [Trait("RFC", "RFC9114-4.3.1")] public void Authority_includes_non_default_port() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com:8443/"); var frames = encoder.Encode(request); @@ -202,8 +202,8 @@ public void Authority_includes_non_default_port() [Trait("RFC", "RFC9114-4.3.1")] public void Authority_omits_default_https_port() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com:443/"); var frames = encoder.Encode(request); @@ -217,8 +217,8 @@ public void Authority_omits_default_https_port() [Trait("RFC", "RFC9114-4.3.1")] public void Pseudo_headers_appear_first() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("accept", "text/html"); @@ -247,7 +247,7 @@ public void Pseudo_headers_appear_first() [Trait("RFC", "RFC9114-4.1")] public void Headers_frame_contains_qpack_block() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); var frames = encoder.Encode(request); @@ -260,8 +260,8 @@ public void Headers_frame_contains_qpack_block() [Trait("RFC", "RFC9114-4.1")] public void Qpack_header_block_decodable() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); var frames = encoder.Encode(request); @@ -275,7 +275,7 @@ public void Qpack_header_block_decodable() [Trait("RFC", "RFC9114-4.1")] public void Dynamic_table_emits_encoder_instructions() { - var encoder = new Http3ClientEncoder(new QpackTableSync(encoderMaxCapacity: 4096)); + var encoder = new Http3ClientEncoder(new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("x-custom-header", "custom-value"); @@ -289,7 +289,7 @@ public void Dynamic_table_emits_encoder_instructions() [Trait("RFC", "RFC9114-4.1")] public void Static_table_only_emits_no_instructions() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); encoder.Encode(request); @@ -301,8 +301,8 @@ public void Static_table_only_emits_no_instructions() [Trait("RFC", "RFC9114-4.1")] public void EncodeToQpackBlock_returns_raw_block() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/test"); var (owner, length) = encoder.EncodeToQpackBlock(request); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderEdgeCasesSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderEdgeCasesSpec.cs index 9f2da4f2b..e9fadbf5f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderEdgeCasesSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestEncoderEdgeCasesSpec.cs @@ -8,7 +8,7 @@ public sealed class Http3RequestEncoderEdgeCasesSpec { private static Http3ClientEncoder CreateEncoder() { - var tableSync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100); + var tableSync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); return new Http3ClientEncoder(tableSync); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestPathAuthoritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestPathAuthoritySpec.cs index 534baf176..d1ab449fe 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestPathAuthoritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3RequestPathAuthoritySpec.cs @@ -8,14 +8,14 @@ public sealed class Http3RequestPathAuthoritySpec { private static Http3ClientEncoder CreateEncoder() { - return new Http3ClientEncoder(new QpackTableSync()); + return new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); } private static IReadOnlyList<(string Name, string Value)> DecodeHeaders(Http3ClientEncoder encoder, HttpRequestMessage request) { var frames = encoder.Encode(request); var headersFrame = (HeadersFrame)frames[0]; - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(0, 100); return decoder.Decode(headersFrame.HeaderBlock.Span); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderEdgeCasesSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderEdgeCasesSpec.cs index 3f49c85f8..5d80ba921 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderEdgeCasesSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderEdgeCasesSpec.cs @@ -7,12 +7,12 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; public sealed class Http3ResponseDecoderEdgeCasesSpec { - private readonly QpackTableSync _tableSync = new(); + private readonly QpackTableSync _tableSync = new(0, 4096, 100, null); private readonly Http3ClientDecoder _decoder; public Http3ResponseDecoderEdgeCasesSpec() { - _decoder = new Http3ClientDecoder(_tableSync); + _decoder = new Http3ClientDecoder(_tableSync, int.MaxValue); } private HeadersFrame EncodeHeaders(params (string Name, string Value)[] headers) diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderSpec.cs index 5094f3163..71012e617 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3ResponseDecoderSpec.cs @@ -7,12 +7,12 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; public sealed class Http3ResponseDecoderSpec { - private readonly QpackTableSync _tableSync = new(); + private readonly QpackTableSync _tableSync = new(0, 4096, 100, null); private readonly Http3ClientDecoder _decoder; public Http3ResponseDecoderSpec() { - _decoder = new Http3ClientDecoder(_tableSync); + _decoder = new Http3ClientDecoder(_tableSync, int.MaxValue); } private HeadersFrame EncodeHeaders(params (string Name, string Value)[] headers) diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3SettingsPopulationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3SettingsPopulationSpec.cs index 4d4ce3495..f8574c89a 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3SettingsPopulationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3SettingsPopulationSpec.cs @@ -19,11 +19,11 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; /// public sealed class Http3SettingsPopulationSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private Http3ClientStateMachine CreateMachine(TurboClientOptions? options = null) { - return new Http3ClientStateMachine(options ?? new TurboClientOptions(), _ops); + return new Http3ClientStateMachine(options ?? new TurboClientOptions(), _clientOps); } private static void SimulateConnect(Http3ClientStateMachine sm) @@ -43,12 +43,12 @@ public void PreStart_should_emit_qpack_max_table_capacity_setting() } }; var sm = CreateMachine(opts); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); - var settings = ExtractSettingsFromOutbound(_ops); + var settings = ExtractSettingsFromOutbound(_clientOps); Assert.NotNull(settings); Assert.Equal(8192L, settings.QpackMaxTableCapacity); } @@ -65,12 +65,12 @@ public void PreStart_should_emit_qpack_blocked_streams_setting() } }; var sm = CreateMachine(opts); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); - var settings = ExtractSettingsFromOutbound(_ops); + var settings = ExtractSettingsFromOutbound(_clientOps); Assert.NotNull(settings); Assert.Equal(50L, settings.QpackBlockedStreams); } @@ -87,12 +87,12 @@ public void PreStart_should_emit_max_field_section_size_setting() } }; var sm = CreateMachine(opts); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); - var settings = ExtractSettingsFromOutbound(_ops); + var settings = ExtractSettingsFromOutbound(_clientOps); Assert.NotNull(settings); Assert.Equal(32768L, settings.MaxFieldSectionSize); } @@ -111,12 +111,12 @@ public void PreStart_should_emit_all_three_settings_when_configured() } }; var sm = CreateMachine(opts); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); - var settings = ExtractSettingsFromOutbound(_ops); + var settings = ExtractSettingsFromOutbound(_clientOps); Assert.NotNull(settings); // Verify all three are present and correct Assert.Equal(4096L, settings.QpackMaxTableCapacity); @@ -129,13 +129,13 @@ public void PreStart_should_emit_all_three_settings_when_configured() public void PreStart_should_emit_settings_on_control_stream() { var sm = CreateMachine(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); // Find control stream data (stream ID -2) containing SETTINGS - var controlStreamData = _ops.Outbound + var controlStreamData = _clientOps.Outbound .OfType() .Where(d => d.StreamId == -2) .ToList(); @@ -144,10 +144,10 @@ public void PreStart_should_emit_settings_on_control_stream() Assert.NotEmpty(controlStreamData); } - private static Http3Settings? ExtractSettingsFromOutbound(FakeOps ops) + private static Http3Settings? ExtractSettingsFromOutbound(FakeClientOps clientOps) { // Find the control stream data (-2) that contains SETTINGS - var controlStreamData = ops.Outbound + var controlStreamData = clientOps.Outbound .OfType() .FirstOrDefault(d => d.StreamId == -2); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3StreamTrackerIntegrationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3StreamTrackerIntegrationSpec.cs new file mode 100644 index 000000000..b4bf53f4e --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/Http3StreamTrackerIntegrationSpec.cs @@ -0,0 +1,99 @@ +using TurboHTTP.Protocol.Multiplexed; +using TurboHTTP.Protocol.Syntax.Http3.Client; +using TurboHTTP.Protocol.Syntax.Http3.Qpack; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client; + +/// +/// Regression tests for the H3 StreamTracker leak bug: +/// OnStreamClosed in Http3ClientSessionManager previously omitted the +/// _tracker.OnStreamClosed() call, causing the active-stream count to +/// grow monotonically and deadlock after MaxConcurrentStreams requests. +/// +public sealed class Http3StreamTrackerIntegrationSpec +{ + private static StreamManager CreateStreamManagerWithCallback(FakeClientOps ops, Action onStreamClosed) + { + var tableSync = new QpackTableSync(0, 0, 0, 0); + var decoder = new Http3ClientDecoder(tableSync, 32 * 1024); + return new StreamManager(ops, decoder, tableSync, long.MaxValue) + { + OnStreamClosedCallback = onStreamClosed + }; + } + + private static StreamManager CreateStreamManagerNoCallback(FakeClientOps ops) + { + var tableSync = new QpackTableSync(0, 0, 0, 0); + var decoder = new Http3ClientDecoder(tableSync, 32 * 1024); + return new StreamManager(ops, decoder, tableSync, long.MaxValue); + } + + [Fact(Timeout = 5000)] + public void OnStreamClosedCallback_should_release_tracker_slot_allowing_new_stream() + { + var ops = new FakeClientOps(); + var tracker = new QuicStreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 1); + var mgr = CreateStreamManagerWithCallback(ops, streamId => tracker.OnStreamClosed(streamId)); + + var streamId = tracker.AllocateStreamId(); + tracker.OnStreamOpened(streamId); + + var state = mgr.GetOrCreateStreamState(streamId); + state.InitResponse(); + + Assert.False(tracker.CanOpenStream()); + + mgr.FlushPendingResponse(streamId); + + Assert.True(tracker.CanOpenStream()); + } + + [Fact(Timeout = 5000)] + public void OnStreamClosedCallback_should_release_all_slots_after_multiple_streams_complete() + { + var ops = new FakeClientOps(); + var tracker = new QuicStreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 2); + var mgr = CreateStreamManagerWithCallback(ops, streamId => tracker.OnStreamClosed(streamId)); + + var id0 = tracker.AllocateStreamId(); + var id1 = tracker.AllocateStreamId(); + tracker.OnStreamOpened(id0); + tracker.OnStreamOpened(id1); + + mgr.GetOrCreateStreamState(id0).InitResponse(); + mgr.GetOrCreateStreamState(id1).InitResponse(); + + Assert.False(tracker.CanOpenStream()); + + mgr.FlushPendingResponse(id0); + Assert.True(tracker.CanOpenStream()); + Assert.Equal(1, tracker.ActiveStreamCount); + + mgr.FlushPendingResponse(id1); + Assert.True(tracker.CanOpenStream()); + Assert.Equal(0, tracker.ActiveStreamCount); + } + + [Fact(Timeout = 5000)] + public void Missing_OnStreamClosedCallback_should_leave_tracker_at_capacity() + { + // Demonstrates the pre-fix behavior: without the callback wired, + // the tracker slot is never released. + var ops = new FakeClientOps(); + var tracker = new QuicStreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 1); + var mgr = CreateStreamManagerNoCallback(ops); + + var streamId = tracker.AllocateStreamId(); + tracker.OnStreamOpened(streamId); + + var state = mgr.GetOrCreateStreamState(streamId); + state.InitResponse(); + + mgr.FlushPendingResponse(streamId); + + // Without the callback, the tracker still thinks the slot is occupied + Assert.False(tracker.CanOpenStream()); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ClientConnectionErrorSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ClientConnectionErrorSpec.cs new file mode 100644 index 000000000..dcf3cf904 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ClientConnectionErrorSpec.cs @@ -0,0 +1,77 @@ +using Servus.Akka.Transport; +using TurboHTTP.Client; +using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Client; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; + +public sealed class Http3ClientConnectionErrorSpec +{ + private readonly FakeClientOps _clientOps = new(); + + private Http3ClientStateMachine CreateMachine() + { + var sm = new Http3ClientStateMachine(new TurboClientOptions(), _clientOps); + sm.PreStart(); + sm.DecodeServerData(new TransportConnected(null!)); + _clientOps.Outbound.Clear(); + return sm; + } + + private static TransportBuffer SerializeFrame(Http3Frame frame) + { + var buffer = TransportBuffer.Rent(frame.SerializedSize); + var span = buffer.FullMemory.Span; + frame.WriteTo(ref span); + buffer.Length = frame.SerializedSize; + return buffer; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-7.2.4")] + public void Second_settings_frame_on_control_stream_should_disconnect() + { + // RFC 9114 §7.2.4: a second SETTINGS frame on the control stream is a connection error. + // It must tear the connection down, not be silently absorbed. + var sm = CreateMachine(); + + sm.DecodeServerData(new MultiplexedData( + SerializeFrame(new SettingsFrame([(SettingsIdentifier.MaxFieldSectionSize, 16384)])), -2)); + sm.DecodeServerData(new MultiplexedData( + SerializeFrame(new SettingsFrame([(SettingsIdentifier.MaxFieldSectionSize, 16384)])), -2)); + + Assert.Contains(_clientOps.Outbound, o => o is DisconnectTransport); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-5.2")] + public void Goaway_with_invalid_stream_id_should_disconnect() + { + // RFC 9114 §5.2: a GOAWAY stream ID that is not a client-initiated bidirectional ID + // (not divisible by 4) is a connection error. It must not be swallowed. + var sm = CreateMachine(); + + sm.DecodeServerData(new MultiplexedData(SerializeFrame(new GoAwayFrame(3)), -2)); + + Assert.Contains(_clientOps.Outbound, o => o is DisconnectTransport); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9204-2.2")] + public void Malformed_response_header_block_should_disconnect() + { + // RFC 9204 §2.2: a HEADERS frame whose QPACK field section indexes a static-table entry far out + // of range desynchronizes the decoder — a connection error. The decode loop must not let it + // escape uncaught; it tears the connection down. + var sm = CreateMachine(); + + // 2-byte field-section prefix (RIC=0, Base=0) + indexed-static line 0xFF + varint(137) -> index 200. + var headerBlock = new byte[] { 0x00, 0x00, 0xFF, 0x89, 0x01 }; + var frame = new HeadersFrame(headerBlock); + + sm.DecodeServerData(new MultiplexedData(SerializeFrame(frame), 0)); + + Assert.Contains(_clientOps.Outbound, o => o is DisconnectTransport); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ControlStreamSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ControlStreamSpec.cs index 2ee022fb5..804386fa1 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ControlStreamSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ControlStreamSpec.cs @@ -9,15 +9,15 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3ControlStreamSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private static readonly ConnectionInfo DummyConnectionInfo = new( new IPEndPoint(IPAddress.Loopback, 5000), new IPEndPoint(IPAddress.Loopback, 443), TransportProtocol.Tcp); - private Http3ClientStateMachine CreateMachine(FakeOps? ops = null) - => new(new TurboClientOptions(), ops ?? _ops); + private Http3ClientStateMachine CreateMachine(FakeClientOps? ops = null) + => new(new TurboClientOptions(), ops ?? _clientOps); private static TransportBuffer SerializeFrame(Http3Frame frame) { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DecoderStreamSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DecoderStreamSpec.cs index 7a0e01428..05dcddf6c 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DecoderStreamSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DecoderStreamSpec.cs @@ -7,11 +7,11 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3DecoderStreamSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private Http3ClientStateMachine CreateMachine(TurboClientOptions? options = null) { - return new Http3ClientStateMachine(options ?? new TurboClientOptions(), _ops); + return new Http3ClientStateMachine(options ?? new TurboClientOptions(), _clientOps); } private static void SimulateConnect(Http3ClientStateMachine sm) @@ -24,13 +24,13 @@ private static void SimulateConnect(Http3ClientStateMachine sm) public void PreStart_should_emit_decoder_stream_opening() { var sm = CreateMachine(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); // PreStart should emit OpenStream for decoder stream (-4) - var openStreams = _ops.Outbound + var openStreams = _clientOps.Outbound .OfType() .ToList(); Assert.Contains(openStreams, s => s.StreamId == -4 && s.Direction == StreamDirection.Unidirectional); @@ -48,13 +48,13 @@ public void PreStart_should_emit_control_stream_preface() } }; var sm = CreateMachine(opts); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); // Should emit control stream data with SETTINGS - var controlData = _ops.Outbound + var controlData = _clientOps.Outbound .OfType() .Where(d => d.StreamId == -2) .ToList(); @@ -68,19 +68,19 @@ public void OnConnectionLost_should_emit_control_streams() var sm = CreateMachine(); sm.PreStart(); SimulateConnect(sm); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); // Create a request to trigger in-flight tracking var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost/"); sm.OnRequest(request); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); // Trigger reconnection by simulating transport disconnect sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); // After disconnect with in-flight requests, control streams should be re-opened // Items are buffered (transport disconnected), so check ConnectTransport was emitted directly - var reconnectControlStreams = _ops.Outbound + var reconnectControlStreams = _clientOps.Outbound .OfType() .Count(); Assert.Equal(1, reconnectControlStreams); @@ -92,7 +92,7 @@ public void DecodeServerData_with_qpack_encoder_updates_should_be_routed_to_enco { var sm = CreateMachine(); sm.PreStart(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); // Feed QPACK encoder stream data (stream ID -3) to trigger state updates var encoderUpdate = "?#B"u8.ToArray(); // Example encoder instruction diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DuplicateStreamSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DuplicateStreamSpec.cs index 8562ba2ad..ece4e2108 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DuplicateStreamSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3DuplicateStreamSpec.cs @@ -8,11 +8,11 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3DuplicateStreamSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private Http3ClientStateMachine CreateMachine() { - return new Http3ClientStateMachine(new TurboClientOptions(), _ops); + return new Http3ClientStateMachine(new TurboClientOptions(), _clientOps); } private static TransportBuffer BuildStreamTypeBuffer(StreamType type, byte[]? trailingData = null) @@ -33,7 +33,7 @@ public void DecodeServerData_should_accept_control_stream_opening() { var sm = CreateMachine(); sm.PreStart(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.DecodeServerData(new ServerStreamAccepted(3, StreamDirection.Unidirectional)); @@ -103,7 +103,7 @@ public void DecodeServerData_should_allow_different_critical_stream_types() { var sm = CreateMachine(); sm.PreStart(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.DecodeServerData(new ServerStreamAccepted(3, StreamDirection.Unidirectional)); var buf1 = BuildStreamTypeBuffer(StreamType.Control, [0x00]); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3GoAwayComplianceSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3GoAwayComplianceSpec.cs index f46af147d..670fc4a83 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3GoAwayComplianceSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3GoAwayComplianceSpec.cs @@ -9,15 +9,15 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3GoAwayComplianceSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private static readonly ConnectionInfo DummyConnectionInfo = new( new IPEndPoint(IPAddress.Loopback, 5000), new IPEndPoint(IPAddress.Loopback, 443), TransportProtocol.Tcp); - private Http3ClientStateMachine CreateMachine(FakeOps? ops = null) - => new(new TurboClientOptions(), ops ?? _ops); + private Http3ClientStateMachine CreateMachine(FakeClientOps? ops = null) + => new(new TurboClientOptions(), ops ?? _clientOps); private static TransportBuffer SerializeFrame(Http3Frame frame) { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3QpackStreamErrorSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3QpackStreamErrorSpec.cs new file mode 100644 index 000000000..71880f07b --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3QpackStreamErrorSpec.cs @@ -0,0 +1,56 @@ +using Servus.Akka.Transport; +using TurboHTTP.Client; +using TurboHTTP.Protocol.Syntax.Http3.Client; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; + +public sealed class Http3QpackStreamErrorSpec +{ + private readonly FakeClientOps _clientOps = new(); + + private Http3ClientStateMachine CreateMachine() + { + var sm = new Http3ClientStateMachine(new TurboClientOptions(), _clientOps); + sm.PreStart(); + sm.DecodeServerData(new TransportConnected(null!)); + _clientOps.Outbound.Clear(); + return sm; + } + + private static TransportBuffer Wrap(byte[] bytes) + { + var buf = TransportBuffer.Rent(bytes.Length); + bytes.CopyTo(buf.FullMemory.Span); + buf.Length = bytes.Length; + return buf; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9204-2.2")] + public void Invalid_qpack_encoder_instruction_should_disconnect_the_connection() + { + // RFC 9204 §2.2 / §3.2.4: an encoder instruction that references a non-existent dynamic-table + // entry is a QPACK_ENCODER_STREAM_ERROR — a connection error. It must NOT be silently absorbed. + // Bytes: 0x80 = Insert With Name Reference, dynamic (T=0), name index 0; 0x00 = empty literal value. + // The dynamic table is empty, so index 0 cannot be resolved. + var sm = CreateMachine(); + + sm.DecodeServerData(new MultiplexedData(Wrap([0x80, 0x00]), -3)); + + Assert.Contains(_clientOps.Outbound, o => o is DisconnectTransport); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9204-2.2")] + public void Valid_qpack_encoder_instruction_should_not_disconnect() + { + // Insert With Literal Name (01Hxxxxx): 0x40 = literal name, H=0, len=0 (empty name); + // 0x00 = empty literal value. A well-formed insert must not tear the connection down. + var sm = CreateMachine(); + + sm.DecodeServerData(new MultiplexedData(Wrap([0x40, 0x00]), -3)); + + Assert.DoesNotContain(_clientOps.Outbound, o => o is DisconnectTransport); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineEdgeCasesSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineEdgeCasesSpec.cs index 50bc5109c..c73960c97 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineEdgeCasesSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineEdgeCasesSpec.cs @@ -7,15 +7,15 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3StateMachineEdgeCasesSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private Http3ClientStateMachine CreateMachine( TurboClientOptions? options = null, - FakeOps? ops = null) + FakeClientOps? ops = null) { return new Http3ClientStateMachine( options ?? new TurboClientOptions(), - ops ?? _ops); + ops ?? _clientOps); } private static void SimulateConnect(Http3ClientStateMachine sm) @@ -28,12 +28,12 @@ private static void SimulateConnect(Http3ClientStateMachine sm) public void PreStart_should_emit_control_streams_and_preface() { var sm = CreateMachine(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); - var outbound = _ops.Outbound.ToList(); + var outbound = _clientOps.Outbound.ToList(); Assert.NotEmpty(outbound); var controlStreamOpens = outbound.OfType() @@ -53,10 +53,10 @@ public void PreStart_should_not_emit_duplicate_preface_on_second_call() { var sm = CreateMachine(); sm.PreStart(); - var firstCallOutbound = _ops.Outbound.Count; + var firstCallOutbound = _clientOps.Outbound.Count; sm.PreStart(); - var secondCallOutbound = _ops.Outbound.Count; + var secondCallOutbound = _clientOps.Outbound.Count; Assert.True(secondCallOutbound >= firstCallOutbound); } @@ -66,12 +66,12 @@ public void PreStart_should_not_emit_duplicate_preface_on_second_call() public void PreStart_should_emit_preface_on_control_stream() { var sm = CreateMachine(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.PreStart(); SimulateConnect(sm); - var prefaces = _ops.Outbound.OfType() + var prefaces = _clientOps.Outbound.OfType() .Where(b => b.StreamId == -2) .ToList(); Assert.NotEmpty(prefaces); @@ -212,7 +212,7 @@ public void ReconnectBufferCount_should_clear_after_reconnect_success() public void OnTimerFired_should_schedule_idle_check() { var sm = CreateMachine(new TurboClientOptions - { Http3 = new Http3Options { IdleTimeout = TimeSpan.FromSeconds(10) } }); + { Http3 = new Http3ClientOptions { IdleTimeout = TimeSpan.FromSeconds(10) } }); sm.PreStart(); sm.OnTimerFired("idle-timeout-check"); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineSpec.cs index 45e0b421b..25dda5b77 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StateMachineSpec.cs @@ -9,7 +9,7 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3StateMachineSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private static readonly ConnectionInfo DummyConnectionInfo = new( new IPEndPoint(IPAddress.Loopback, 5000), @@ -18,11 +18,11 @@ public sealed class Http3StateMachineSpec private Http3ClientStateMachine CreateMachine( TurboClientOptions? options = null, - FakeOps? ops = null) + FakeClientOps? ops = null) { return new Http3ClientStateMachine( options ?? new TurboClientOptions(), - ops ?? _ops); + ops ?? _clientOps); } private static TransportBuffer SerializeFrame(Http3Frame frame) @@ -49,7 +49,7 @@ public void PreStart_should_emit_control_stream_setup() SimulateConnect(sm); // Should emit OpenStream messages for control streams - Assert.NotEmpty(_ops.Outbound); + Assert.NotEmpty(_clientOps.Outbound); } [Fact(Timeout = 5000)] @@ -171,7 +171,7 @@ public void DecodeServerData_should_reject_push_promise_with_cancel_push() sm.DecodeServerData(new MultiplexedData(buffer, -2)); // Should have emitted a CancelPush response on control stream - Assert.Contains(_ops.Outbound, o => o is MultiplexedData md && md.StreamId < 0); + Assert.Contains(_clientOps.Outbound, o => o is MultiplexedData md && md.StreamId < 0); } [Fact(Timeout = 5000)] @@ -218,8 +218,8 @@ public void DecodeServerData_should_forward_headers_frame_to_app() var sm = CreateMachine(); sm.PreStart(); sm.OnRequest(CreateGetRequest()); - _ops.Outbound.Clear(); - _ops.Responses.Clear(); + _clientOps.Outbound.Clear(); + _clientOps.Responses.Clear(); var qpack = new TurboHTTP.Protocol.Syntax.Http3.Qpack.QpackEncoder(maxTableCapacity: 0); var headers = new HeadersFrame(qpack.Encode([(":status", "200")])); @@ -238,7 +238,7 @@ public void DecodeServerData_should_forward_data_frame_to_app() var sm = CreateMachine(); sm.PreStart(); sm.OnRequest(CreateGetRequest()); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); var data = new DataFrame("He"u8.ToArray()); var buffer = SerializeFrame(data); @@ -253,11 +253,11 @@ public void OnRequest_should_emit_serialized_frames_via_outbound_callback() { var sm = CreateMachine(); sm.PreStart(); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.OnRequest(CreateGetRequest()); - Assert.NotEmpty(_ops.Outbound); + Assert.NotEmpty(_clientOps.Outbound); } [Fact(Timeout = 5000)] @@ -318,12 +318,12 @@ public void OnRequest_should_buffer_frames_during_reconnect() sm.PreStart(); sm.OnRequest(CreateGetRequest()); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.OnRequest(CreateGetRequest()); Assert.True(sm.ReconnectBufferCount > 0); - Assert.Empty(_ops.Outbound); // not emitted during reconnect + Assert.Empty(_clientOps.Outbound); // not emitted during reconnect } [Fact(Timeout = 5000)] @@ -334,7 +334,7 @@ public void OnConnectionRestored_should_replay_buffered_frames() sm.PreStart(); sm.OnRequest(CreateGetRequest()); sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error)); - _ops.Outbound.Clear(); + _clientOps.Outbound.Clear(); sm.OnRequest(CreateGetRequest()); var bufferedCount = sm.ReconnectBufferCount; @@ -344,7 +344,7 @@ public void OnConnectionRestored_should_replay_buffered_frames() Assert.False(sm.IsReconnecting); Assert.Equal(0, sm.ReconnectBufferCount); - Assert.NotEmpty(_ops.Outbound); // replayed frames + Assert.NotEmpty(_clientOps.Outbound); // replayed frames } [Fact(Timeout = 5000)] @@ -415,7 +415,7 @@ public void OnUpstreamFinished_should_flush_all_pending_responses() sm.OnUpstreamFinished(); - Assert.Equal(2, _ops.Responses.Count); + Assert.Equal(2, _clientOps.Responses.Count); } [Fact(Timeout = 5000)] @@ -442,14 +442,14 @@ public void DecodeServerData_should_isolate_per_stream_state() sm.OnRequest(CreateGetRequest("https://example.com/b")); // Responses are emitted on HEADERS (streaming model) - Assert.Equal(2, _ops.Responses.Count); - Assert.Equal(HttpStatusCode.OK, _ops.Responses[0].StatusCode); - Assert.Equal(HttpStatusCode.NotFound, _ops.Responses[1].StatusCode); + Assert.Equal(2, _clientOps.Responses.Count); + Assert.Equal(HttpStatusCode.OK, _clientOps.Responses[0].StatusCode); + Assert.Equal(HttpStatusCode.NotFound, _clientOps.Responses[1].StatusCode); // StreamReadCompleted completes the body handles sm.DecodeServerData(new StreamReadCompleted(4)); sm.DecodeServerData(new StreamReadCompleted(0)); - Assert.Equal(2, _ops.Responses.Count); + Assert.Equal(2, _clientOps.Responses.Count); } [Fact(Timeout = 5000)] @@ -471,8 +471,8 @@ public void DecodeServerData_should_correlate_by_stream_id() sm.DecodeServerData(new MultiplexedData(SerializeFrame(headers), 4)); sm.DecodeServerData(new StreamReadCompleted(4)); - Assert.Single(_ops.Responses); - Assert.Same(req2, _ops.Responses[0].RequestMessage); + Assert.Single(_clientOps.Responses); + Assert.Same(req2, _clientOps.Responses[0].RequestMessage); } [Fact(Timeout = 5000)] @@ -482,12 +482,12 @@ public void OnRequest_should_tag_outbound_frames_with_stream_id() var sm = CreateMachine(); sm.PreStart(); SimulateConnect(sm); - _ops.Outbound.Clear(); // Clear control stream setup frames + _clientOps.Outbound.Clear(); // Clear control stream setup frames sm.OnRequest(CreateGetRequest()); // All request frames should be tagged as MultiplexedData with stream ID 0 - var tagged = _ops.Outbound + var tagged = _clientOps.Outbound .OfType() .ToList(); Assert.NotEmpty(tagged); @@ -501,12 +501,12 @@ public void OnRequest_should_assign_distinct_stream_ids_to_concurrent_requests() var sm = CreateMachine(); sm.PreStart(); SimulateConnect(sm); - _ops.Outbound.Clear(); // Clear control stream setup frames + _clientOps.Outbound.Clear(); // Clear control stream setup frames sm.OnRequest(CreateGetRequest("https://example.com/a")); sm.OnRequest(CreateGetRequest("https://example.com/b")); - var tagged = _ops.Outbound.OfType().ToList(); + var tagged = _clientOps.Outbound.OfType().ToList(); Assert.NotEmpty(tagged); var streamIds = tagged.Select(t => t.StreamId).Distinct().ToList(); Assert.Equal(2, streamIds.Count); @@ -517,7 +517,7 @@ public void OnRequest_should_assign_distinct_stream_ids_to_concurrent_requests() public void OnTimerFired_should_handle_idle_timeout() { var sm = CreateMachine(new TurboClientOptions - { Http3 = new Http3Options { IdleTimeout = TimeSpan.FromMilliseconds(1) } }); + { Http3 = new Http3ClientOptions { IdleTimeout = TimeSpan.FromMilliseconds(1) } }); sm.PreStart(); // Timer firing should check idle timeout and potentially emit GoAway diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamLifecycleSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamLifecycleSpec.cs index 4f63ec6b6..31188fd86 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamLifecycleSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamLifecycleSpec.cs @@ -10,15 +10,15 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3StreamLifecycleSpec { - private readonly FakeOps _ops = new(); + private readonly FakeClientOps _clientOps = new(); private static readonly ConnectionInfo DummyConnectionInfo = new( new IPEndPoint(IPAddress.Loopback, 5000), new IPEndPoint(IPAddress.Loopback, 443), TransportProtocol.Tcp); - private Http3ClientStateMachine CreateMachine(FakeOps? ops = null) - => new(new TurboClientOptions(), ops ?? _ops); + private Http3ClientStateMachine CreateMachine(FakeClientOps? ops = null) + => new(new TurboClientOptions(), ops ?? _clientOps); private static void SimulateConnect(Http3ClientStateMachine sm) => sm.DecodeServerData(new TransportConnected(DummyConnectionInfo)); @@ -37,7 +37,7 @@ public void StateMachine_should_accept_request_when_connected() [Trait("RFC", "RFC9114-4.1")] public void Encoder_should_produce_single_headers_frame_per_request() { - var tableSync = new QpackTableSync(); + var tableSync = new QpackTableSync(0, 4096, 100, null); var encoder = new Http3ClientEncoder(tableSync); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); var frames = encoder.Encode(request); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamRoutingSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamRoutingSpec.cs index 935527005..a4fcad5ef 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamRoutingSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamRoutingSpec.cs @@ -9,14 +9,14 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Client.StateMachine; public sealed class Http3StreamRoutingSpec { - private readonly FakeOps _ops = new(); - private readonly QpackTableSync _tableSync = new(); + private readonly FakeClientOps _clientOps = new(); + private readonly QpackTableSync _tableSync = new(0, 4096, 100, null); - private Http3ClientStateMachine CreateMachine(FakeOps? ops = null) + private Http3ClientStateMachine CreateMachine(FakeClientOps? ops = null) { return new Http3ClientStateMachine( new TurboClientOptions(), - ops ?? _ops); + ops ?? _clientOps); } private HeadersFrame EncodeHeaders(params (string Name, string Value)[] headers) @@ -72,14 +72,14 @@ public async Task DecodeServerData_should_use_per_stream_decoders() sm.DecodeServerData(new StreamReadCompleted(4)); // Verify responses were assembled with correct data integrity - Assert.Equal(2, _ops.Responses.Count); + Assert.Equal(2, _clientOps.Responses.Count); // Verify stream 0's response body is all 0xAA - var body0 = await _ops.Responses[0].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + var body0 = await _clientOps.Responses[0].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); Assert.True(body0.All(b => b == 0xAA), "Stream 0 body corrupted"); // Verify stream 4's response body is all 0xBB - var body4 = await _ops.Responses[1].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + var body4 = await _clientOps.Responses[1].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); Assert.True(body4.All(b => b == 0xBB), "Stream 4 body corrupted"); } @@ -90,39 +90,31 @@ public async Task AssembleResponse_should_route_data_to_correct_stream_with_60KB var sm = CreateMachine(); const int bodySize = 60 * 1024; // 60KB per stream - // Simulate two concurrent request streams - // Stream 0: filled with 0xAA - // Stream 4: filled with 0xBB + // QueuedBodyReader has a fixed slot capacity: once full, TryEnqueue returns false. + // Send full body in a single HEADERS+DATA buffer per stream, interleaved across + // streams, to verify routing correctness without exceeding the queue capacity. - // Decode HEADERS + partial DATA for stream 0 - var buf0 = BuildResponseBuffer(0xAA, bodySize / 2); + // Stream 0: HEADERS + 60KB DATA (filled with 0xAA) + var buf0 = BuildResponseBuffer(0xAA, bodySize); sm.DecodeServerData(new MultiplexedData(buf0, 0)); - // Interleave: decode HEADERS + partial DATA for stream 4 - var buf4 = BuildResponseBuffer(0xBB, bodySize / 2); + // Stream 4: HEADERS + 60KB DATA (filled with 0xBB) + var buf4 = BuildResponseBuffer(0xBB, bodySize); sm.DecodeServerData(new MultiplexedData(buf4, 4)); - // More DATA for stream 0 (second half) - var buf0B = BuildDataBuffer(0xAA, bodySize / 2); - sm.DecodeServerData(new MultiplexedData(buf0B, 0)); - - // More DATA for stream 4 (second half) - var buf4B = BuildDataBuffer(0xBB, bodySize / 2); - sm.DecodeServerData(new MultiplexedData(buf4B, 4)); - // Signal EOF to flush both responses sm.DecodeServerData(new StreamReadCompleted(0)); sm.DecodeServerData(new StreamReadCompleted(4)); - Assert.Equal(2, _ops.Responses.Count); + Assert.Equal(2, _clientOps.Responses.Count); // Verify stream 0 response body is all 0xAA - var body0 = await _ops.Responses[0].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + var body0 = await _clientOps.Responses[0].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); Assert.Equal(bodySize, body0.Length); Assert.True(body0.All(b => b == 0xAA), "Stream 0 body corrupted — contains bytes from another stream"); // Verify stream 4 response body is all 0xBB - var body4 = await _ops.Responses[1].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + var body4 = await _clientOps.Responses[1].Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); Assert.Equal(bodySize, body4.Length); Assert.True(body4.All(b => b == 0xBB), "Stream 4 body corrupted — contains bytes from another stream"); } @@ -165,8 +157,8 @@ public void DecodeServerData_should_handle_fragmented_data_across_multiple_calls sm.DecodeServerData(new StreamReadCompleted(0)); // Response should be assembled despite fragmentation - Assert.Single(_ops.Responses); - var body = _ops.Responses[0].Content.ReadAsStream(TestContext.Current.CancellationToken); + Assert.Single(_clientOps.Responses); + var body = _clientOps.Responses[0].Content.ReadAsStream(TestContext.Current.CancellationToken); var buffer = new byte[512]; var bytesRead = body.Read(buffer); Assert.Equal(512, bytesRead); @@ -198,9 +190,9 @@ public void DecodeServerData_should_isolate_control_stream_from_request_streams( // Flush to get response sm.DecodeServerData(new StreamReadCompleted(0)); - Assert.Single(_ops.Responses); + Assert.Single(_clientOps.Responses); var bodyBuffer = new byte[512]; - var bodyStream = _ops.Responses[0].Content.ReadAsStream(TestContext.Current.CancellationToken); + var bodyStream = _clientOps.Responses[0].Content.ReadAsStream(TestContext.Current.CancellationToken); var bytesRead = bodyStream.Read(bodyBuffer); var body = bodyBuffer.Take(bytesRead).ToArray(); Assert.Equal(512, body.Length); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamTrackerSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamTrackerSpec.cs index 5cf314ede..60e1e88e5 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamTrackerSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3StreamTrackerSpec.cs @@ -8,7 +8,7 @@ public sealed class Http3StreamTrackerSpec [Trait("RFC", "RFC9114-6")] public void AllocateStreamId_should_return_zero_for_first_allocation() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); var id = tracker.AllocateStreamId(); @@ -19,7 +19,7 @@ public void AllocateStreamId_should_return_zero_for_first_allocation() [Trait("RFC", "RFC9114-6")] public void AllocateStreamId_should_increment_by_four() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); var first = tracker.AllocateStreamId(); var second = tracker.AllocateStreamId(); @@ -34,7 +34,7 @@ public void AllocateStreamId_should_increment_by_four() [Trait("RFC", "RFC9114-6")] public void AllocateStreamId_should_use_custom_initial_id() { - var tracker = new StreamTracker(initialNextStreamId: 12); + var tracker = new StreamTracker(initialNextStreamId: 12, maxConcurrentStreams: 100); var id = tracker.AllocateStreamId(); @@ -46,7 +46,7 @@ public void AllocateStreamId_should_use_custom_initial_id() [Trait("RFC", "RFC9114-6")] public void NextStreamId_should_reflect_current_counter() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); Assert.Equal(0L, tracker.NextStreamId); tracker.AllocateStreamId(); @@ -59,7 +59,7 @@ public void NextStreamId_should_reflect_current_counter() [Trait("RFC", "RFC9114-6")] public void CanOpenStream_should_return_true_when_below_limit() { - var tracker = new StreamTracker(maxConcurrentStreams: 2); + var tracker = new StreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 2); Assert.True(tracker.CanOpenStream()); } @@ -68,7 +68,7 @@ public void CanOpenStream_should_return_true_when_below_limit() [Trait("RFC", "RFC9114-6")] public void CanOpenStream_should_return_false_when_at_limit() { - var tracker = new StreamTracker(maxConcurrentStreams: 2); + var tracker = new StreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 2); tracker.OnStreamOpened(0); tracker.OnStreamOpened(4); @@ -79,7 +79,7 @@ public void CanOpenStream_should_return_false_when_at_limit() [Trait("RFC", "RFC9114-6")] public void CanOpenStream_should_return_true_after_stream_closed() { - var tracker = new StreamTracker(maxConcurrentStreams: 1); + var tracker = new StreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 1); tracker.OnStreamOpened(0); Assert.False(tracker.CanOpenStream()); @@ -93,7 +93,7 @@ public void CanOpenStream_should_return_true_after_stream_closed() [Trait("RFC", "RFC9114-6")] public void OnStreamOpened_should_increment_active_count() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); Assert.Equal(0, tracker.ActiveStreamCount); tracker.OnStreamOpened(0); @@ -106,7 +106,7 @@ public void OnStreamOpened_should_increment_active_count() [Trait("RFC", "RFC9114-6")] public void OnStreamClosed_should_decrement_active_count() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); tracker.OnStreamOpened(0); tracker.OnStreamOpened(4); @@ -119,7 +119,7 @@ public void OnStreamClosed_should_decrement_active_count() [Trait("RFC", "RFC9114-6")] public void OnStreamClosed_should_return_false_for_unknown_stream() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); var result = tracker.OnStreamClosed(99); @@ -131,7 +131,7 @@ public void OnStreamClosed_should_return_false_for_unknown_stream() [Trait("RFC", "RFC9114-6")] public void OnStreamClosed_should_return_true_for_tracked_stream() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); tracker.OnStreamOpened(0); var result = tracker.OnStreamClosed(0); @@ -143,7 +143,7 @@ public void OnStreamClosed_should_return_true_for_tracked_stream() [Trait("RFC", "RFC9114-6")] public void Reset_should_clear_active_streams() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); tracker.OnStreamOpened(0); tracker.OnStreamOpened(4); @@ -156,7 +156,7 @@ public void Reset_should_clear_active_streams() [Trait("RFC", "RFC9114-6")] public void Reset_should_restart_stream_id_allocation_from_zero() { - var tracker = new StreamTracker(); + var tracker = new StreamTracker(0, 100); tracker.AllocateStreamId(); // 0 tracker.AllocateStreamId(); // 4 @@ -170,7 +170,7 @@ public void Reset_should_restart_stream_id_allocation_from_zero() [Trait("RFC", "RFC9114-6")] public void MaxConcurrentStreams_should_be_settable() { - var tracker = new StreamTracker(maxConcurrentStreams: 1); + var tracker = new StreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 1); tracker.OnStreamOpened(0); Assert.False(tracker.CanOpenStream()); @@ -185,7 +185,7 @@ public void MaxConcurrentStreams_should_be_settable() public void StreamIds_should_support_large_values() { // QUIC uses 62-bit variable-length integers — verify long works for large IDs - var tracker = new StreamTracker(initialNextStreamId: 4_611_686_018_427_387_900L); + var tracker = new StreamTracker(initialNextStreamId: 4_611_686_018_427_387_900L, maxConcurrentStreams: 100); var id = tracker.AllocateStreamId(); @@ -199,7 +199,7 @@ public void StreamIds_should_support_large_values() [Trait("RFC", "RFC9114-6")] public void StreamTracker_should_use_configured_max_concurrent_streams() { - var tracker = new StreamTracker(maxConcurrentStreams: 250); + var tracker = new StreamTracker(initialNextStreamId: 0, maxConcurrentStreams: 250); for (var i = 0; i < 250; i++) { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StreamManagerPoolSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StreamManagerPoolSpec.cs index 304934f89..68a467c18 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StreamManagerPoolSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StreamManagerPoolSpec.cs @@ -9,10 +9,10 @@ public sealed class StreamManagerPoolSpec [Fact(Timeout = 5000)] public void Pool_should_recycle_up_to_256_stream_states() { - var ops = new FakeOps(); + var ops = new FakeClientOps(); var tableSync = new QpackTableSync(0, 4 * 1024, 100, 4 * 1024); var decoder = new Http3ClientDecoder(tableSync, 16 * 1024); - var mgr = new StreamManager(ops, decoder, tableSync); + var mgr = new StreamManager(ops, decoder, tableSync, long.MaxValue); for (var i = 0; i < 256; i++) { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptionsSpec.cs index 253f74955..f20b633b8 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptionsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptionsSpec.cs @@ -1,5 +1,4 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http3.Options; +using TurboHTTP.Client; namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Options; @@ -7,25 +6,8 @@ public sealed class Http3ClientDecoderOptionsSpec { [Fact(Timeout = 5000)] [Trait("RFC", "RFC9114-7.2.4")] - public void Default_should_hold_SharedHttpOptions_Default() + public void Default_client_options_should_project_sensible_decoder_values() { - Assert.Same(SharedHttpOptions.Default, Http3ClientDecoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { StreamingThreshold = -1 }; - var opts = Http3ClientDecoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_reject_invalid_MaxConcurrentStreams() - { - var opts = Http3ClientDecoderOptions.Default with { MaxConcurrentStreams = 0 }; - Assert.Throws(opts.Validate); + Assert.Equal(100, new TurboClientOptions().ToHttp3DecoderOptions().MaxConcurrentStreams); } } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptionsSpec.cs index 79c2b5af2..bf9d6bc46 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptionsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptionsSpec.cs @@ -1,5 +1,4 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http3.Options; +using TurboHTTP.Client; namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Options; @@ -7,25 +6,8 @@ public sealed class Http3ClientEncoderOptionsSpec { [Fact(Timeout = 5000)] [Trait("RFC", "RFC9114-7.2.4")] - public void Default_should_hold_SharedHttpOptions_Default() + public void Default_client_options_should_project_sensible_encoder_values() { - Assert.Same(SharedHttpOptions.Default, Http3ClientEncoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { MaxHeaderCount = 0 }; - var opts = Http3ClientEncoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_reject_invalid_QpackMaxTableCapacity() - { - var opts = Http3ClientEncoderOptions.Default with { QpackMaxTableCapacity = -1 }; - Assert.Throws(opts.Validate); + Assert.Equal(100, new TurboClientOptions().ToHttp3EncoderOptions().QpackBlockedStreams); } } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ServerDecoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ServerDecoderOptionsSpec.cs deleted file mode 100644 index 06e5170af..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ServerDecoderOptionsSpec.cs +++ /dev/null @@ -1,31 +0,0 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http3.Options; - -namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Options; - -public sealed class Http3ServerDecoderOptionsSpec -{ - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Default_should_hold_SharedHttpOptions_Default() - { - Assert.Same(SharedHttpOptions.Default, Http3ServerDecoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { StreamingThreshold = -1 }; - var opts = Http3ServerDecoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_reject_invalid_MaxConcurrentStreams() - { - var opts = Http3ServerDecoderOptions.Default with { MaxConcurrentStreams = 0 }; - Assert.Throws(opts.Validate); - } -} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ServerEncoderOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ServerEncoderOptionsSpec.cs deleted file mode 100644 index eefea1d4f..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Options/Http3ServerEncoderOptionsSpec.cs +++ /dev/null @@ -1,31 +0,0 @@ -using TurboHTTP.Protocol.Syntax; -using TurboHTTP.Protocol.Syntax.Http3.Options; - -namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Options; - -public sealed class Http3ServerEncoderOptionsSpec -{ - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Default_should_hold_SharedHttpOptions_Default() - { - Assert.Same(SharedHttpOptions.Default, Http3ServerEncoderOptions.Default.Shared); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_delegate_to_Shared() - { - var bad = SharedHttpOptions.Default with { MaxHeaderCount = 0 }; - var opts = Http3ServerEncoderOptions.Default with { Shared = bad }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - [Trait("RFC", "RFC9114-7.2.4")] - public void Validate_should_reject_invalid_QpackMaxTableCapacity() - { - var opts = Http3ServerEncoderOptions.Default with { QpackMaxTableCapacity = -1 }; - Assert.Throws(opts.Validate); - } -} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDecoderSpec.cs index e77353892..987e63c3b 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDecoderSpec.cs @@ -15,7 +15,7 @@ public void Should_DecodeStaticIndexed_When_StaticTableMatch() var headers = new List<(string, string)> { (":method", "GET") }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var decoded = decoder.Decode(encoded.Span); Assert.Single(decoded); @@ -36,7 +36,7 @@ public void Should_DecodeMultipleStaticIndexed() }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var decoded = decoder.Decode(encoded.Span); Assert.Equal(3, decoded.Count); @@ -57,7 +57,7 @@ public void Should_DecodeDynamicIndexed_When_DynamicTablePopulated() var encoded = encoder.Encode(headers); // Decoder must have the same dynamic table state - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); decoder.DynamicTable.Insert("x-custom", "value1"); var decoded = decoder.Decode(encoded.Span); @@ -76,7 +76,7 @@ public void Should_DecodeLiteralWithStaticNameRef() var headers = new List<(string, string)> { (":path", "/very/long/path/that/exceeds/capacity") }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 32); + var decoder = new QpackDecoder(maxTableCapacity: 32, 100); var decoded = decoder.Decode(encoded.Span); Assert.Single(decoded); @@ -92,7 +92,7 @@ public void Should_DecodeLiteralWithoutNameRef() var headers = new List<(string, string)> { ("x-custom", "my-value") }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var decoded = decoder.Decode(encoded.Span); Assert.Single(decoded); @@ -108,7 +108,7 @@ public void Should_DecodeSensitiveHeader_When_NeverIndexed() var headers = new List<(string, string)> { ("authorization", "Bearer token123") }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); var decoded = decoder.Decode(encoded.Span); Assert.Single(decoded); @@ -124,7 +124,7 @@ public void Should_DecodeHuffman_When_StringIsHuffmanEncoded() var headers = new List<(string, string)> { ("x-test", "www.example.com") }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var decoded = decoder.Decode(encoded.Span); Assert.Single(decoded); @@ -142,7 +142,7 @@ public void Should_Throw_When_RequiredInsertCountExceedsKnown() var encoded = encoder.Encode(headers); // Decoder has empty dynamic table (InsertCount = 0) but encoded block has RIC = 1 - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); Assert.Throws(() => decoder.Decode(encoded.Span)); } @@ -192,7 +192,7 @@ public void Should_EmitSectionAcknowledgment_When_DynamicTableReferenced() var headers = new List<(string, string)> { ("x-custom", "value1") }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); decoder.DynamicTable.Insert("x-custom", "value1"); decoder.Decode(encoded.Span, streamId: 4); @@ -218,7 +218,7 @@ public void Should_EmitNoInstructions_When_StaticOnly() var headers = new List<(string, string)> { (":method", "GET") }; var encoded = encoder.Encode(headers); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); decoder.Decode(encoded.Span); Assert.Equal(0, decoder.DecoderInstructions.Length); @@ -240,7 +240,7 @@ public void Should_DecodePostBaseIndexed() // Post-base indexed: 0001xxxx, 4-bit prefix, postBaseIndex=0 WriteInt(0, 4, 0x10, buf); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); decoder.DynamicTable.Insert("x-post-base", "pb-value"); var decoded = decoder.Decode(buf.WrittenSpan, streamId: 1); @@ -269,7 +269,7 @@ public void Should_DecodeLiteralWithPostBaseNameRef() var valueBytes = "new-value"u8.ToArray(); WriteStr(valueBytes.AsSpan(), 7, 0x00, false, buf); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); decoder.DynamicTable.Insert("x-post-name", "original"); var decoded = decoder.Decode(buf.WrittenSpan, streamId: 1); @@ -286,7 +286,7 @@ public void Should_DecodeEmptyHeaderBlock() var encoder = new QpackEncoder(maxTableCapacity: 0); var encoded = encoder.Encode(new List<(string, string)>()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var decoded = decoder.Decode(encoded.Span); Assert.Empty(decoded); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDynamicTableActivationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDynamicTableActivationSpec.cs index 3033775f0..033947011 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDynamicTableActivationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackDynamicTableActivationSpec.cs @@ -72,7 +72,7 @@ public void SetMaxCapacity_to_zero_should_disable_dynamic_table() [Trait("RFC", "RFC9204-3.2.3")] public void TableSync_should_activate_encoder_via_UpdateEncoderCapacity() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, configuredEncoderLimit: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: 4096); sync.UpdateEncoderCapacity(8192); @@ -87,7 +87,7 @@ public void TableSync_should_activate_encoder_via_UpdateEncoderCapacity() [Trait("RFC", "RFC9204-3.2.3")] public void UpdateEncoderCapacity_should_cap_at_configured_limit() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, configuredEncoderLimit: 2048); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: 2048); sync.UpdateEncoderCapacity(16384); @@ -98,7 +98,7 @@ public void UpdateEncoderCapacity_should_cap_at_configured_limit() [Trait("RFC", "RFC9204-3.2.3")] public void UpdateEncoderCapacity_should_use_peer_value_when_smaller() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, configuredEncoderLimit: 16384); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: 16384); sync.UpdateEncoderCapacity(1024); @@ -109,7 +109,7 @@ public void UpdateEncoderCapacity_should_use_peer_value_when_smaller() [Trait("RFC", "RFC9204-3.2.3")] public void UpdateEncoderCapacity_should_noop_when_peer_sends_zero() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, configuredEncoderLimit: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: 4096); sync.UpdateEncoderCapacity(0); @@ -125,7 +125,7 @@ public void UpdateEncoderCapacity_should_noop_when_peer_sends_zero() [Trait("RFC", "RFC9204-3.2.3")] public void UpdateEncoderCapacity_should_noop_when_configured_limit_is_zero() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, configuredEncoderLimit: 0); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: 0); sync.UpdateEncoderCapacity(4096); @@ -141,7 +141,7 @@ public void UpdateEncoderCapacity_should_noop_when_configured_limit_is_zero() [Trait("RFC", "RFC9204-3.2.3")] public void Reset_should_return_encoder_to_disabled_state() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, configuredEncoderLimit: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: 4096); sync.UpdateEncoderCapacity(4096); Assert.Equal(4096, sync.Encoder.DynamicTable.Capacity); @@ -160,7 +160,7 @@ public void Reset_should_return_encoder_to_disabled_state() [Trait("RFC", "RFC9204-3.2.3")] public void Encoder_should_roundtrip_after_activation() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, configuredEncoderLimit: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: 4096); sync.UpdateEncoderCapacity(4096); var headers = new List<(string, string)> diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackIntegrationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackIntegrationSpec.cs index 01d838df9..aca3af3b6 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackIntegrationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackIntegrationSpec.cs @@ -11,7 +11,7 @@ public sealed class QpackIntegrationSpec [Trait("RFC", "RFC9114-4.1")] public void Encoder_should_produce_headers_frame_with_qpack() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/index.html"); var frames = encoder.Encode(request); @@ -25,8 +25,8 @@ public void Encoder_should_produce_headers_frame_with_qpack() [Trait("RFC", "RFC9114-4.1")] public void Encoder_should_produce_output_decodable_by_qpack_decoder() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/path?q=1"); request.Headers.TryAddWithoutValidation("accept", "text/html"); @@ -51,7 +51,7 @@ public void Encoder_should_produce_output_decodable_by_qpack_decoder() [Trait("RFC", "RFC9114-4.1")] public void Encoder_should_produce_headers_only_for_body_request() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/api/data") { Content = new StringContent("hello world", Encoding.UTF8, "text/plain"), @@ -67,8 +67,8 @@ public void Encoder_should_produce_headers_only_for_body_request() [Trait("RFC", "RFC9114-4.2")] public void Encoder_should_filter_forbidden_headers() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("connection", "keep-alive"); @@ -86,7 +86,7 @@ public void Encoder_should_filter_forbidden_headers() [Trait("RFC", "RFC9114-4.1")] public void Encoder_should_emit_qpack_encoder_instructions() { - var encoder = new Http3ClientEncoder(new QpackTableSync(encoderMaxCapacity: 4096)); + var encoder = new Http3ClientEncoder(new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/"); request.Headers.TryAddWithoutValidation("x-custom", "custom-value"); @@ -104,7 +104,7 @@ public void Decoder_should_emit_qpack_decoder_instructions() { // Use dynamic table: encoder inserts entries, decoder should emit section ack var qpackEncoder = new QpackEncoder(maxTableCapacity: 4096); - var qpackDecoder = new QpackDecoder(maxTableCapacity: 4096); + var qpackDecoder = new QpackDecoder(maxTableCapacity: 4096, 100); var headers = new List<(string Name, string Value)> { @@ -173,7 +173,7 @@ public void Decoder_should_emit_qpack_decoder_instructions() [Trait("RFC", "RFC9114-4.3")] public void Encoder_should_reject_null_uri() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, (Uri?)null); Assert.Throws(() => encoder.Encode(request)); } diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackRoundTripSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackRoundTripSpec.cs index 33971718e..7d6168136 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackRoundTripSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackRoundTripSpec.cs @@ -9,7 +9,7 @@ public sealed class QpackRoundTripSpec public void Should_RoundTrip_StaticOnly() { var encoder = new QpackEncoder(maxTableCapacity: 0); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var headers = new List<(string, string)> { @@ -36,7 +36,7 @@ public void Should_RoundTrip_StaticIndexedAndLiteral() { // Capacity 0 disables dynamic table → forces static refs + pure literals var encoder = new QpackEncoder(maxTableCapacity: 0); - var decoder = new QpackDecoder(maxTableCapacity: 0); + var decoder = new QpackDecoder(maxTableCapacity: 0, 100); var headers = new List<(string, string)> { @@ -62,7 +62,7 @@ public void Should_RoundTrip_StaticIndexedAndLiteral() public void Should_RoundTrip_DynamicTableEntries() { var encoder = new QpackEncoder(maxTableCapacity: 4096); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); var headers = new List<(string, string)> { @@ -90,7 +90,7 @@ public void Should_RoundTrip_DynamicTableEntries() public void Should_RoundTrip_RepeatedHeadersReuseDynamicTable() { var encoder = new QpackEncoder(maxTableCapacity: 4096); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); var headers = new List<(string, string)> { @@ -123,7 +123,7 @@ public void Should_RoundTrip_RepeatedHeadersReuseDynamicTable() public void Should_RoundTrip_SensitiveHeaders() { var encoder = new QpackEncoder(maxTableCapacity: 4096); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); var headers = new List<(string, string)> { @@ -155,7 +155,7 @@ public void Should_RoundTrip_SensitiveHeaders() public void Should_RoundTrip_MixedSensitiveAndNormal() { var encoder = new QpackEncoder(maxTableCapacity: 4096); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); var headers = new List<(string, string)> { @@ -185,7 +185,7 @@ public void Should_RoundTrip_MixedSensitiveAndNormal() public void Should_RoundTrip_LargeHeaderList() { var encoder = new QpackEncoder(maxTableCapacity: 4096); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); var headers = new List<(string, string)> { @@ -219,7 +219,7 @@ public void Should_RoundTrip_LargeHeaderList() public void Should_RoundTrip_EmptyHeaderList() { var encoder = new QpackEncoder(maxTableCapacity: 4096); - var decoder = new QpackDecoder(maxTableCapacity: 4096); + var decoder = new QpackDecoder(maxTableCapacity: 4096, 100); var encoded = encoder.Encode(new List<(string, string)>()); var decoded = decoder.Decode(encoded.Span); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncEdgeCasesSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncEdgeCasesSpec.cs index 886c05883..1e29ab410 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncEdgeCasesSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncEdgeCasesSpec.cs @@ -9,7 +9,7 @@ public sealed class QpackTableSyncEdgeCasesSpec [Trait("RFC", "RFC9204-2.1")] public void Should_Initialize_With_Zero_EncoderCapacity() { - var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); Assert.Equal(0, sync.Encoder.DynamicTable.Capacity); Assert.Equal(4096, sync.Decoder.DynamicTable.Capacity); @@ -19,7 +19,7 @@ public void Should_Initialize_With_Zero_EncoderCapacity() [Trait("RFC", "RFC9204-2.1")] public void Should_Initialize_With_Large_Capacities() { - var sync = new QpackTableSync(encoderMaxCapacity: 65536, decoderMaxCapacity: 65536, maxBlockedStreams: 1000); + var sync = new QpackTableSync(encoderMaxCapacity: 65536, decoderMaxCapacity: 65536, maxBlockedStreams: 1000, configuredEncoderLimit: null); Assert.Equal(65536, sync.Encoder.DynamicTable.Capacity); Assert.Equal(65536, sync.Decoder.DynamicTable.Capacity); @@ -30,7 +30,7 @@ public void Should_Initialize_With_Large_Capacities() public void Should_Throw_On_Negative_MaxBlockedStreams() { var ex = Assert.Throws(() => - new QpackTableSync(maxBlockedStreams: -1)); + new QpackTableSync(encoderMaxCapacity: 0, decoderMaxCapacity: 4096, maxBlockedStreams: -1, configuredEncoderLimit: null)); Assert.Equal("maxBlockedStreams", ex.ParamName); } @@ -39,7 +39,7 @@ public void Should_Throw_On_Negative_MaxBlockedStreams() [Trait("RFC", "RFC9204-2.1")] public void Should_Throw_When_BlockingExceeds_MaxBlockedStreams_Zero() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 0); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 0, configuredEncoderLimit: null); var encoder = sync.Encoder; var headers = new List<(string, string)> { ("x-test", "value") }; @@ -56,7 +56,7 @@ public void Should_Throw_When_BlockingExceeds_MaxBlockedStreams_Zero() [Trait("RFC", "RFC9204-2.1")] public void Should_Throw_When_MaxBlockedStreams_Exceeded() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 2); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 2, configuredEncoderLimit: null); var encoder = sync.Encoder; // Create and block first two streams with custom headers that will be inserted @@ -88,7 +88,7 @@ public void Should_Throw_When_MaxBlockedStreams_Exceeded() [Trait("RFC", "RFC9204-4.3")] public void Should_Handle_Empty_EncoderInstructions() { - var sync = new QpackTableSync(); + var sync = new QpackTableSync(0, 4096, 100, null); var data = ReadOnlySpan.Empty; var count = sync.ProcessEncoderInstructions(data); @@ -100,7 +100,7 @@ public void Should_Handle_Empty_EncoderInstructions() [Trait("RFC", "RFC9204-4.3")] public void Should_Apply_Multiple_EncoderInstructions() { - var sync = new QpackTableSync(encoderMaxCapacity: 256); + var sync = new QpackTableSync(encoderMaxCapacity: 256, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var encoder = sync.Encoder; // Encode multiple unique headers to generate multiple insert instructions @@ -125,7 +125,7 @@ public void Should_Apply_Multiple_EncoderInstructions() [Trait("RFC", "RFC9204-4.4")] public void Should_Handle_Empty_DecoderInstructions() { - var sync = new QpackTableSync(); + var sync = new QpackTableSync(0, 4096, 100, null); var data = ReadOnlySpan.Empty; var count = sync.ProcessDecoderInstructions(data); @@ -137,7 +137,7 @@ public void Should_Handle_Empty_DecoderInstructions() [Trait("RFC", "RFC9204-4.4.1")] public void Should_Update_EncoderKnownReceivedCount_OnSectionAck() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var encoder = sync.Encoder; // Insert entry into encoder's dynamic table @@ -161,7 +161,7 @@ public void Should_Update_EncoderKnownReceivedCount_OnSectionAck() [Trait("RFC", "RFC9204-4.4.3")] public void Should_Process_InsertCountIncrement_InDecoderInstructions() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var encoder = sync.Encoder; Assert.Equal(0, encoder.KnownReceivedCount); @@ -181,7 +181,7 @@ public void Should_Process_InsertCountIncrement_InDecoderInstructions() [Trait("RFC", "RFC9204-4.4.2")] public void Should_Remove_Only_Cancelled_BlockedStream() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; // Block multiple streams @@ -207,7 +207,7 @@ public void Should_Remove_Only_Cancelled_BlockedStream() [Trait("RFC", "RFC9204-2.1.2")] public void Should_Resolve_Only_Ready_BlockedStreams() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; // Create three headers to get InsertCount = 3 @@ -246,7 +246,7 @@ public void Should_Resolve_Only_Ready_BlockedStreams() [Trait("RFC", "RFC9204-2.1.2")] public void Should_Resolve_All_BlockedStreams_When_ConditionMet() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; // Block multiple streams @@ -282,7 +282,7 @@ public void Should_Resolve_All_BlockedStreams_When_ConditionMet() [Trait("RFC", "RFC9204-4.4.3")] public void Should_Have_Zero_KnownReceivedCount_Initially() { - var sync = new QpackTableSync(); + var sync = new QpackTableSync(0, 4096, 100, null); Assert.Equal(0, sync.KnownReceivedCount); } @@ -291,7 +291,7 @@ public void Should_Have_Zero_KnownReceivedCount_Initially() [Trait("RFC", "RFC9204-4.4.3")] public void Should_Return_Zero_Increment_When_NoChange() { - var sync = new QpackTableSync(); + var sync = new QpackTableSync(0, 4096, 100, null); var buf = new byte[16]; var writer = SpanWriter.Create(buf); @@ -304,7 +304,7 @@ public void Should_Return_Zero_Increment_When_NoChange() [Trait("RFC", "RFC9204-4.4.3")] public void Should_Update_KnownReceivedCount_OnWriteIncrement() { - var sync = new QpackTableSync(); + var sync = new QpackTableSync(0, 4096, 100, null); // Insert entries into decoder's table sync.Decoder.DynamicTable.Insert("x-test-1", "value1"); @@ -325,7 +325,7 @@ public void Should_Update_KnownReceivedCount_OnWriteIncrement() [Trait("RFC", "RFC9204-2.1")] public void Should_Reset_ClearsAllState() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); // Add some state - manually insert to have tracked state sync.Decoder.DynamicTable.Insert("x-test-1", "value1"); @@ -348,7 +348,7 @@ public void Should_Reset_ClearsAllState() [Trait("RFC", "RFC9204-4.3")] public void Should_Apply_SetDynamicTableCapacity_Instruction() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); // Manually emit a SetCapacity instruction (simulating encoder instruction) // and apply it to decoder @@ -382,7 +382,7 @@ public void Should_Apply_SetDynamicTableCapacity_Instruction() [Trait("RFC", "RFC9204-4.3")] public void Should_Apply_Duplicate_Instruction() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); // Insert initial entry sync.Decoder.DynamicTable.Insert("x-test", "original"); @@ -415,7 +415,7 @@ public void Should_Apply_Duplicate_Instruction() [Trait("RFC", "RFC9204-2.1")] public void Should_Maintain_Accurate_BlockedStreamCount() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; Assert.Equal(0, sync.BlockedStreamCount); @@ -446,7 +446,7 @@ public void Should_Maintain_Accurate_BlockedStreamCount() [Trait("RFC", "RFC9204-2.1")] public void Should_Return_Current_InsertCount() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); Assert.Equal(0, sync.InsertCount); @@ -461,7 +461,7 @@ public void Should_Return_Current_InsertCount() [Trait("RFC", "RFC9204-4.3")] public void Should_Apply_InsertWithNameReference_Static() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); // Write Insert with name reference to static table (e.g., :method = value) var buffer = new byte[32]; diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncSpec.cs index bc57cf83c..016fb2f7f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Qpack/QpackTableSyncSpec.cs @@ -9,7 +9,7 @@ public sealed class QpackTableSyncSpec [Trait("RFC", "RFC9204-2.1.1")] public void Should_SyncDecoderTable_ViaEncoderInstructions() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var encoder = sync.Encoder; var decoder = sync.Decoder; @@ -43,7 +43,7 @@ public void Should_SyncDecoderTable_ViaEncoderInstructions() [Trait("RFC", "RFC9204-2.1.1")] public void Should_TrackInsertCount_AcrossMultipleHeaderBlocks() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var encoder = sync.Encoder; var decoder = sync.Decoder; @@ -82,7 +82,7 @@ public void Should_TrackInsertCount_AcrossMultipleHeaderBlocks() [Trait("RFC", "RFC9204-2.1.1")] public void Should_SkipInserts_WhenHeadersAlreadyInTable() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var encoder = sync.Encoder; var decoder = sync.Decoder; @@ -114,7 +114,7 @@ public void Should_SkipInserts_WhenHeadersAlreadyInTable() [Trait("RFC", "RFC9204-2.1.2")] public void Should_BlockStream_WhenRequiredInsertCountExceedsKnown() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; var headers = new List<(string, string)> @@ -138,7 +138,7 @@ public void Should_BlockStream_WhenRequiredInsertCountExceedsKnown() [Trait("RFC", "RFC9204-2.1.2")] public void Should_ResolveBlockedStream_WhenInsertCountReached() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; var headers = new List<(string, string)> @@ -170,7 +170,7 @@ public void Should_ResolveBlockedStream_WhenInsertCountReached() [Trait("RFC", "RFC9204-2.1.2")] public void Should_ResolveMultipleBlockedStreams_InBatch() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; // Encode two different header blocks @@ -211,7 +211,7 @@ public void Should_ResolveMultipleBlockedStreams_InBatch() [Trait("RFC", "RFC9204-4.4.3")] public void Should_UpdateKnownReceivedCount_ViaInsertCountIncrement() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); var decoder = sync.Decoder; // Manually insert entries into decoder's table (simulating encoder instructions) @@ -231,7 +231,7 @@ public void Should_UpdateKnownReceivedCount_ViaInsertCountIncrement() // Process the instruction on the encoder side — QpackTableSync forwards // decoder instructions to the QpackEncoder, updating its KnownReceivedCount. - var encoderSync = new QpackTableSync(encoderMaxCapacity: 4096); + var encoderSync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); encoderSync.ProcessDecoderInstructions(buf[..written]); Assert.Equal(2, encoderSync.Encoder.KnownReceivedCount); @@ -241,7 +241,7 @@ public void Should_UpdateKnownReceivedCount_ViaInsertCountIncrement() [Trait("RFC", "RFC9204-4.4.2")] public void Should_RemoveBlockedStream_OnStreamCancellation() { - var sync = new QpackTableSync(encoderMaxCapacity: 4096, maxBlockedStreams: 10); + var sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 10, configuredEncoderLimit: null); var encoder = sync.Encoder; var headers = new List<(string, string)> { ("x-cancel-me", "will-cancel") }; diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerDecoderSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerDecoderSecuritySpec.cs index b492a448f..6dd556612 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerDecoderSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerDecoderSecuritySpec.cs @@ -1,4 +1,5 @@ using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Syntax.Http3.Server; @@ -6,13 +7,21 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server; public sealed class Http3ServerDecoderSecuritySpec { - private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 0, decoderMaxCapacity: 0); - private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 0, decoderMaxCapacity: 0); + private static Http3ServerDecoderOptions DefaultDecoderOptions() => new() + { + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + + private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 0, decoderMaxCapacity: 0, maxBlockedStreams: 100, configuredEncoderLimit: null); + private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 0, decoderMaxCapacity: 0, maxBlockedStreams: 100, configuredEncoderLimit: null); private readonly Http3ServerDecoder _decoder; public Http3ServerDecoderSecuritySpec() { - _decoder = new Http3ServerDecoder(_decoderTableSync); + _decoder = new Http3ServerDecoder(_decoderTableSync, DefaultDecoderOptions()); } private HeadersFrame EncodeAndSync(List<(string Name, string Value)> headers) @@ -266,7 +275,7 @@ public void DecodeHeaders_CONNECT_without_authority_should_reject() [Trait("RFC", "RFC9114-4.2.2")] public void DecodeHeaders_should_reject_field_section_exceeding_max_size() { - var decoderWithLimit = new Http3ServerDecoder(_decoderTableSync, maxFieldSectionSize: 128); + var decoderWithLimit = new Http3ServerDecoder(_decoderTableSync, DefaultDecoderOptions() with { MaxFieldSectionSize = 128 }); var headers = new List<(string Name, string Value)> { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerEncoderHardeningSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerEncoderHardeningSpec.cs index bffba4a59..f166b86d5 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerEncoderHardeningSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerEncoderHardeningSpec.cs @@ -1,6 +1,7 @@ using System.Buffers; using Microsoft.AspNetCore.Http.Features; using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Syntax.Http3.Server; using TurboHTTP.Tests.Shared; @@ -9,13 +10,21 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server; public sealed class Http3ServerEncoderHardeningSpec { - private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); - private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); + private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); + private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); private readonly Http3ServerEncoder _encoder; public Http3ServerEncoderHardeningSpec() { - _encoder = new Http3ServerEncoder(_encoderTableSync); + var options = new Http3ServerEncoderOptions + { + WriteDateHeader = false, + QpackMaxTableCapacity = 4096, + QpackBlockedStreams = 100, + MaxHeaderBytes = 8192, + UseHuffman = true + }; + _encoder = new Http3ServerEncoder(_encoderTableSync, options); } [Fact(Timeout = 5000)] @@ -99,11 +108,19 @@ public void EncodeHeaders_multiple_responses_should_not_cross_contaminate() ctx2.Get()?.Headers["x-second"] = "second-value"; // Encode response1 with its own encoder/decoder pair - var encoder1Sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); - var encoder1 = new Http3ServerEncoder(encoder1Sync); + var encoder1Sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); + var options1 = new Http3ServerEncoderOptions + { + WriteDateHeader = false, + QpackMaxTableCapacity = 4096, + QpackBlockedStreams = 100, + MaxHeaderBytes = 8192, + UseHuffman = true + }; + var encoder1 = new Http3ServerEncoder(encoder1Sync, options1); var frame1 = encoder1.EncodeHeaders(ctx1); - var decoderSync1 = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); + var decoderSync1 = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); if (!encoder1.EncoderInstructions.IsEmpty) { decoderSync1.ProcessEncoderInstructions(encoder1.EncoderInstructions.Span); @@ -112,11 +129,19 @@ public void EncodeHeaders_multiple_responses_should_not_cross_contaminate() var decoded1 = decoderSync1.Decoder.Decode(frame1.HeaderBlock.Span, streamId: 1); // Encode response2 with its own encoder/decoder pair - var encoder2Sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); - var encoder2 = new Http3ServerEncoder(encoder2Sync); + var encoder2Sync = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); + var options2 = new Http3ServerEncoderOptions + { + WriteDateHeader = false, + QpackMaxTableCapacity = 4096, + QpackBlockedStreams = 100, + MaxHeaderBytes = 8192, + UseHuffman = true + }; + var encoder2 = new Http3ServerEncoder(encoder2Sync, options2); var frame2 = encoder2.EncodeHeaders(ctx2); - var decoderSync2 = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); + var decoderSync2 = new QpackTableSync(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); if (!encoder2.EncoderInstructions.IsEmpty) { decoderSync2.ProcessEncoderInstructions(encoder2.EncoderInstructions.Span); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerMaxFieldSectionSizeSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerMaxFieldSectionSizeSpec.cs new file mode 100644 index 000000000..9027c6b74 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerMaxFieldSectionSizeSpec.cs @@ -0,0 +1,146 @@ +using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Options; +using TurboHTTP.Protocol.Syntax.Http3.Qpack; +using TurboHTTP.Protocol.Syntax.Http3.Server; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server; + +public sealed class Http3ServerMaxFieldSectionSizeSpec +{ + private static Http3ServerDecoderOptions DefaultDecoderOptions() => new() + { + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + + private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 0, decoderMaxCapacity: 0, maxBlockedStreams: 100, configuredEncoderLimit: null); + private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 0, decoderMaxCapacity: 0, maxBlockedStreams: 100, configuredEncoderLimit: null); + + private HeadersFrame EncodeAndSync(List<(string Name, string Value)> headers) + { + var headerBlock = _encoderTableSync.Encoder.Encode(headers); + var instructions = _encoderTableSync.Encoder.EncoderInstructions; + if (!instructions.IsEmpty) + { + _decoderTableSync.ProcessEncoderInstructions(instructions.Span); + } + + return new HeadersFrame(headerBlock); + } + + private static StreamState MakeState(long streamId = 1) + { + var state = new StreamState(); + state.Initialize(streamId); + return state; + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.2.2")] + public void DecodeHeaders_with_limit_should_reject_headers_exceeding_max_field_section_size() + { + var maxFieldSectionSize = 256; + var decoderOptions = DefaultDecoderOptions() with { MaxFieldSectionSize = maxFieldSectionSize }; + var decoder = new Http3ServerDecoder(_decoderTableSync, decoderOptions); + + var headers = new List<(string Name, string Value)> + { + (":method", "GET"), + (":path", "/"), + (":scheme", "https"), + (":authority", "example.com"), + ("x-large-header", new string('x', 300)), + }; + + var frame = EncodeAndSync(headers); + var state = MakeState(); + + var ex = Assert.Throws(() => + decoder.DecodeHeadersToFeature(frame, state, endStream: true)); + Assert.Contains("SETTINGS_MAX_FIELD_SECTION_SIZE", ex.Message); + Assert.Contains("RFC 9114", ex.Message); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.2.2")] + public void DecodeHeaders_with_limit_should_accept_headers_under_max_field_section_size() + { + var maxFieldSectionSize = 512; + var decoderOptions = DefaultDecoderOptions() with { MaxFieldSectionSize = maxFieldSectionSize }; + var decoder = new Http3ServerDecoder(_decoderTableSync, decoderOptions); + + var headers = new List<(string Name, string Value)> + { + (":method", "GET"), + (":path", "/"), + (":scheme", "https"), + (":authority", "example.com"), + ("x-small-header", "value"), + }; + + var frame = EncodeAndSync(headers); + var state = MakeState(); + + var feature = decoder.DecodeHeadersToFeature(frame, state, endStream: true); + Assert.NotNull(feature); + Assert.Equal("GET", feature.Method); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.2.2")] + public void DecodeHeaders_many_small_headers_exceeding_max_field_section_size_should_be_rejected() + { + var maxFieldSectionSize = 320; + var decoderOptions = DefaultDecoderOptions() with { MaxFieldSectionSize = maxFieldSectionSize }; + var decoder = new Http3ServerDecoder(_decoderTableSync, decoderOptions); + + var headers = new List<(string Name, string Value)> + { + (":method", "GET"), + (":path", "/"), + (":scheme", "https"), + (":authority", "example.com"), + ("x-header-1", new string('a', 40)), + ("x-header-2", new string('b', 40)), + ("x-header-3", new string('c', 40)), + ("x-header-4", new string('d', 40)), + ("x-header-5", new string('e', 40)), + }; + + var frame = EncodeAndSync(headers); + var state = MakeState(); + + var ex = Assert.Throws(() => + decoder.DecodeHeadersToFeature(frame, state, endStream: true)); + Assert.Contains("SETTINGS_MAX_FIELD_SECTION_SIZE", ex.Message); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.2.2")] + public void DecodeHeaders_default_options_should_allow_normal_requests() + { + var decoder = new Http3ServerDecoder(_decoderTableSync, DefaultDecoderOptions()); + + var headers = new List<(string Name, string Value)> + { + (":method", "POST"), + (":path", "/api/data"), + (":scheme", "https"), + (":authority", "api.example.com"), + ("content-type", "application/json"), + ("content-length", "1024"), + ("user-agent", "test-client/1.0"), + ("accept-encoding", "gzip, deflate"), + }; + + var frame = EncodeAndSync(headers); + var state = MakeState(); + + var feature = decoder.DecodeHeadersToFeature(frame, state, endStream: true); + Assert.NotNull(feature); + Assert.Equal("POST", feature.Method); + Assert.Equal("/api/data", feature.Path); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerOptionsResolutionSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerOptionsResolutionSpec.cs new file mode 100644 index 000000000..c4827f8da --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerOptionsResolutionSpec.cs @@ -0,0 +1,48 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server; + +public sealed class Http3ServerOptionsResolutionSpec +{ + [Fact(Timeout = 5000)] + public void Body_override_should_win_else_limits() + { + var o = new TurboServerOptions + { + Http3 = + { + MaxRequestBodySize = 777 + } + }; + Assert.Equal(777, o.ToHttp3Options().Limits.MaxRequestBodySize); + + var o2 = new TurboServerOptions + { + Limits = + { + MaxRequestBodySize = 888 + } + }; + Assert.Equal(888, o2.ToHttp3Options().Limits.MaxRequestBodySize); + } + + [Fact(Timeout = 5000)] + public void QpackBlockedStreams_should_flow_from_Http3ServerOptions_to_ConnectionOptions() + { + var opts = new TurboServerOptions + { + Http3 = + { + QpackBlockedStreams = 42 + } + }; + Assert.Equal(42, opts.ToHttp3Options().QpackBlockedStreams); + } + + [Fact(Timeout = 5000)] + public void QpackBlockedStreams_default_should_be_100() + { + var opts = new TurboServerOptions(); + Assert.Equal(100, opts.ToHttp3Options().QpackBlockedStreams); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineSpec.cs index cda7ae934..307d2e140 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineSpec.cs @@ -55,7 +55,7 @@ private static ReadOnlyMemory EncodeHeaders( public void PreStart_should_open_control_and_qpack_streams() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); sm.PreStart(); @@ -82,7 +82,7 @@ public void PreStart_should_open_control_and_qpack_streams() public void PreStart_should_emit_settings_on_control_stream() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); sm.PreStart(); @@ -101,7 +101,7 @@ public void PreStart_should_emit_settings_on_control_stream() public void DecodeClientData_with_headers_should_produce_request_with_stream_id() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 4; // Client-initiated bidirectional stream @@ -143,7 +143,7 @@ public void DecodeClientData_with_headers_should_produce_request_with_stream_id( public async Task DecodeClientData_with_headers_and_data_should_accumulate_body() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 8; // Different stream ID const string bodyContent = "Hello, World!"; @@ -199,7 +199,7 @@ public async Task DecodeClientData_with_headers_and_data_should_accumulate_body( public void OnResponse_no_body_should_emit_HEADERS_and_CompleteWrites() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 12; @@ -244,10 +244,10 @@ public void OnResponse_no_body_should_emit_HEADERS_and_CompleteWrites() [Fact(Timeout = 5000)] [Trait("RFC", "RFC9114-6.2")] - public void OnResponse_with_body_should_schedule_drain_timer() + public void OnResponse_with_body_should_emit_headers_and_start_body_drain() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 12; @@ -279,10 +279,6 @@ public void OnResponse_with_body_should_schedule_drain_timer() var frameItems = ops.Outbound.OfType().ToList(); Assert.NotEmpty(frameItems); Assert.Equal(streamId, frameItems[0].StreamId.Value); - - // Should schedule drain-body timer - Assert.True(ops.ScheduledTimers.Any(t => t.Name == $"drain-body:{streamId}"), - "Should schedule drain-body timer"); } [Fact(Timeout = 5000)] @@ -290,7 +286,7 @@ public void OnResponse_with_body_should_schedule_drain_timer() public void DecodeClientData_with_multiple_streams_should_multiplex() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); // Stream 1 const long stream1 = 0; @@ -348,7 +344,7 @@ public void DecodeClientData_with_multiple_streams_should_multiplex() public void OnDownstreamFinished_should_flush_pending_requests() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 4; @@ -377,7 +373,7 @@ public void OnDownstreamFinished_should_flush_pending_requests() public void Cleanup_should_dispose_stream_decoders() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 4; diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineTimerSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineTimerSpec.cs index 3c2ba7aa8..5e44e61ef 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineTimerSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3ServerStateMachineTimerSpec.cs @@ -47,7 +47,7 @@ public void PreStart_should_schedule_keep_alive_timer() KeepAliveTimeout = TimeSpan.FromSeconds(130) } }; - var sm = new Http3ServerStateMachine(options, ops); + var sm = new Http3ServerStateMachine(options.ToHttp3Options(), ops); sm.PreStart(); @@ -63,7 +63,7 @@ public void PreStart_should_schedule_keep_alive_timer() public void ShouldComplete_should_always_be_false() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); Assert.False(sm.ShouldComplete, "ShouldComplete should be false after construction"); @@ -79,7 +79,7 @@ public void ShouldComplete_should_always_be_false() public void Stream_open_should_cancel_keep_alive() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); sm.PreStart(); @@ -101,7 +101,7 @@ public void Stream_open_should_cancel_keep_alive() public void OnTimerFired_headers_timeout_should_emit_RstStream() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 4; @@ -127,7 +127,7 @@ public void OnTimerFired_headers_timeout_should_emit_RstStream() public void Cleanup_should_be_idempotent() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); sm.PreStart(); SendRequest(sm, 4); @@ -147,7 +147,7 @@ public void Cleanup_should_be_idempotent() public void OnDownstreamFinished_should_flush_pending() { var ops = new FakeServerOps(); - var sm = new Http3ServerStateMachine(new TurboServerOptions(), ops); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); const long streamId = 4; diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3StreamStateBackpressureSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3StreamStateBackpressureSpec.cs new file mode 100644 index 000000000..cb17e5086 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Http3StreamStateBackpressureSpec.cs @@ -0,0 +1,118 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http3; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server; + +public sealed class Http3StreamStateBackpressureSpec +{ + private static StreamBodyChunk Chunk(int len) + { + var owner = MemoryPool.Shared.Rent(len); + return new StreamBodyChunk(owner, len); + } + + [Fact(Timeout = 5000)] + public void Enqueue_should_accumulate_pending_bytes() + { + var state = new StreamState(); + + state.EnqueueBodyChunk(Chunk(60)); + Assert.Equal(60, state.PendingOutboundBytes); + + state.EnqueueBodyChunk(Chunk(40)); + Assert.Equal(100, state.PendingOutboundBytes); + } + + [Fact(Timeout = 5000)] + public void Dequeue_should_reduce_pending_bytes() + { + var state = new StreamState(); + + state.EnqueueBodyChunk(Chunk(60)); + state.EnqueueBodyChunk(Chunk(40)); + + state.TryDequeueBodyChunk(out var c1); + c1!.Owner.Dispose(); + Assert.Equal(40, state.PendingOutboundBytes); + + state.TryDequeueBodyChunk(out var c2); + c2!.Owner.Dispose(); + Assert.Equal(0, state.PendingOutboundBytes); + } + + [Fact(Timeout = 5000)] + public void Reset_should_clear_pending_bytes_and_drain_state() + { + var state = new StreamState(); + + state.MarkBodyDrainActive(); + state.EnqueueBodyChunk(Chunk(120)); + + state.Reset(); + + Assert.Equal(0, state.PendingOutboundBytes); + Assert.False(state.HasBodyDrain); + Assert.False(state.IsBodyDrainComplete); + } + + [Fact(Timeout = 5000)] + public void MarkBodyDrainActive_should_set_drain_flags() + { + var state = new StreamState(); + + state.MarkBodyDrainActive(); + + Assert.True(state.HasBodyDrain); + Assert.False(state.IsBodyDrainComplete); + } + + [Fact(Timeout = 5000)] + public void MarkBodyDrainComplete_should_set_complete_flag() + { + var state = new StreamState(); + + state.MarkBodyDrainActive(); + state.MarkBodyDrainComplete(); + + Assert.True(state.HasBodyDrain); + Assert.True(state.IsBodyDrainComplete); + } + + [Fact(Timeout = 5000)] + public void InitBodyReader_should_set_HasBodyReader() + { + var state = new StreamState(); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + + state.InitBodyReader(reader); + + Assert.True(state.HasBodyReader); + } + + [Fact(Timeout = 5000)] + public void DetachBodyReader_should_clear_HasBodyReader() + { + var state = new StreamState(); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + state.InitBodyReader(reader); + + state.DetachBodyReader(); + + Assert.False(state.HasBodyReader); + } + + [Fact(Timeout = 5000)] + public void FeedBody_should_reject_when_exceeding_max_size() + { + var state = new StreamState(); + var reader = new QueuedBodyReader(capacity: 4); + reader.Reset(); + state.InitBodyReader(reader, maxBodySize: 10); + + var data = new byte[11]; + Assert.Throws(() => state.FeedBody(data, endStream: false)); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/IntermediaryEncapsulationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/IntermediaryEncapsulationSpec.cs index 9e64f83c7..ab285376a 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/IntermediaryEncapsulationSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/IntermediaryEncapsulationSpec.cs @@ -264,7 +264,7 @@ public void Connect_uri_with_userinfo_rejected() [Trait("RFC", "RFC9114-10.3")] public void Encoder_rejects_userinfo_uri() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://user:pass@example.com/"); var ex = Assert.Throws(() => encoder.Encode(request)); @@ -275,7 +275,7 @@ public void Encoder_rejects_userinfo_uri() [Trait("RFC", "RFC9114-10.3")] public void Encoder_rejects_fragment_uri() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/page#section"); var ex = Assert.Throws(() => encoder.Encode(request)); @@ -286,7 +286,7 @@ public void Encoder_rejects_fragment_uri() [Trait("RFC", "RFC9114-10.3")] public void Encoder_accepts_normal_request() { - var encoder = new Http3ClientEncoder(new QpackTableSync()); + var encoder = new Http3ClientEncoder(new QpackTableSync(0, 4096, 100, null)); var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/path?q=1"); var frames = encoder.Encode(request); diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Security/Http3ServerSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Security/Http3ServerSecuritySpec.cs index 25b3e3c20..193040d7d 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Security/Http3ServerSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/Security/Http3ServerSecuritySpec.cs @@ -1,4 +1,5 @@ using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Syntax.Http3.Server; @@ -6,6 +7,14 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.Security; public sealed class Http3ServerSecuritySpec { + private static Http3ServerDecoderOptions DefaultDecoderOptions() => new() + { + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + private readonly QpackTableSync _encoderSync = new(0, 0, 0, 0); private readonly QpackTableSync _decoderSync = new(0, 0, 0, 0); @@ -32,7 +41,7 @@ private static StreamState MakeState(long id = 1) [Trait("RFC", "RFC9114-4.2.2")] public void Field_section_exceeding_max_size_should_be_rejected() { - var decoder = new Http3ServerDecoder(_decoderSync, maxFieldSectionSize: 128); + var decoder = new Http3ServerDecoder(_decoderSync, DefaultDecoderOptions() with { MaxFieldSectionSize = 128 }); var headers = new List<(string Name, string Value)> { @@ -55,7 +64,7 @@ public void Field_section_exceeding_max_size_should_be_rejected() [Trait("RFC", "RFC9114-4.2.2")] public void Many_small_headers_exceeding_total_field_section_size_should_be_rejected() { - var decoder = new Http3ServerDecoder(_decoderSync, maxFieldSectionSize: 256); + var decoder = new Http3ServerDecoder(_decoderSync, DefaultDecoderOptions() with { MaxFieldSectionSize = 256 }); var headers = new List<(string Name, string Value)> { @@ -82,7 +91,7 @@ public void Many_small_headers_exceeding_total_field_section_size_should_be_reje [Trait("RFC", "RFC9114-4.2")] public void Uppercase_header_name_should_be_rejected() { - var decoder = new Http3ServerDecoder(_decoderSync); + var decoder = new Http3ServerDecoder(_decoderSync, DefaultDecoderOptions()); var headers = new List<(string Name, string Value)> { @@ -105,7 +114,7 @@ public void Uppercase_header_name_should_be_rejected() [Trait("RFC", "RFC9114-10.3")] public void Header_value_with_null_byte_should_be_rejected() { - var decoder = new Http3ServerDecoder(_decoderSync); + var decoder = new Http3ServerDecoder(_decoderSync, DefaultDecoderOptions()); var headers = new List<(string Name, string Value)> { diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerRequestDecoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerRequestDecoderSpec.cs index 53b6c43e7..db6f619bb 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerRequestDecoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerRequestDecoderSpec.cs @@ -1,4 +1,5 @@ using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Syntax.Http3.Server; @@ -6,13 +7,21 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server; public sealed class ServerRequestDecoderSpec { - private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); - private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); + private static Http3ServerDecoderOptions DefaultDecoderOptions() => new() + { + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + }; + + private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); + private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); private readonly Http3ServerDecoder _decoder; public ServerRequestDecoderSpec() { - _decoder = new Http3ServerDecoder(_decoderTableSync); + _decoder = new Http3ServerDecoder(_decoderTableSync, DefaultDecoderOptions()); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerResponseEncoderSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerResponseEncoderSpec.cs index 825f99081..09a5e2aaa 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerResponseEncoderSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/ServerResponseEncoderSpec.cs @@ -1,6 +1,7 @@ using System.Buffers; using Microsoft.AspNetCore.Http.Features; using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Syntax.Http3.Server; using TurboHTTP.Tests.Shared; @@ -9,13 +10,21 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server; public sealed class ServerResponseEncoderSpec { - private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); - private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096); + private readonly QpackTableSync _encoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); + private readonly QpackTableSync _decoderTableSync = new(encoderMaxCapacity: 4096, decoderMaxCapacity: 4096, maxBlockedStreams: 100, configuredEncoderLimit: null); private readonly Http3ServerEncoder _encoder; public ServerResponseEncoderSpec() { - _encoder = new Http3ServerEncoder(_encoderTableSync); + var options = new Http3ServerEncoderOptions + { + WriteDateHeader = false, + QpackMaxTableCapacity = 4096, + QpackBlockedStreams = 100, + MaxHeaderBytes = 8192, + UseHuffman = true + }; + _encoder = new Http3ServerEncoder(_encoderTableSync, options); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3BodyRateTimeoutSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3BodyRateTimeoutSpec.cs index 70b6c7f23..1d4618b7b 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3BodyRateTimeoutSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3BodyRateTimeoutSpec.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http3; -using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Tests.Shared; @@ -11,6 +11,7 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; public sealed class Http3BodyRateTimeoutSpec { + private static byte[] BuildRequest(string method, string path) { var tableSync = new QpackTableSync(0, 0, 0, 0); @@ -39,16 +40,35 @@ private static byte[] BuildDataFrameBytes(int size) return buf; } + private static Http3ConnectionOptions DefaultConnectionOptions() => new() + { + Limits = new ResolvedServerLimits( + MaxRequestBodySize: 30 * 1024 * 1024, + KeepAliveTimeout: TimeSpan.FromSeconds(130), + RequestHeadersTimeout: TimeSpan.FromSeconds(30), + MinRequestBodyDataRate: 240, + MinRequestBodyDataRateGracePeriod: TimeSpan.FromSeconds(5), + MinResponseDataRate: 240, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(5)), + MaxConcurrentStreams = 100, + MaxHeaderListSize = 32 * 1024, + MaxHeaderCount = 100, + QpackMaxTableCapacity = 0, + QpackBlockedStreams = 0, + MaxResponseBufferSize = 64 * 1024, + ResponseBodyChunkSize = 16 * 1024, + BodyConsumptionTimeout = TimeSpan.FromSeconds(30), + UseHuffman = true, + }; + private static Http3ServerSessionManager CreateSM(FakeServerOps ops) { - var enc = new Http3ServerEncoderOptions { QpackMaxTableCapacity = 0 }; - var dec = new Http3ServerDecoderOptions { MaxConcurrentStreams = 100 }; - return new Http3ServerSessionManager(enc, dec, ops); + return new Http3ServerSessionManager(DefaultConnectionOptions(), ops); } [Fact(Timeout = 5000)] [Trait("RFC", "RFC9114-4.3")] - public void First_DATA_frame_should_schedule_body_rate_check() + public void First_DATA_frame_should_schedule_data_rate_check() { var ops = new FakeServerOps(); var sm = CreateSM(ops); @@ -81,9 +101,9 @@ public void First_DATA_frame_should_schedule_body_rate_check() dataBuffer.Length = dataBytes.Length; sm.DecodeClientData(new MultiplexedData(dataBuffer, streamId)); - // body-rate-check timer should now be scheduled - Assert.True(ops.ScheduledTimers.Any(t => t.Name == "body-rate-check"), - "body-rate-check timer should be scheduled after first DATA frame"); + // data-rate-check timer should now be scheduled + Assert.True(ops.ScheduledTimers.Any(t => t.Name == "data-rate-check"), + "data-rate-check timer should be scheduled after first DATA frame"); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3ConnectionErrorTeardownSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3ConnectionErrorTeardownSpec.cs new file mode 100644 index 000000000..67e0b1a74 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3ConnectionErrorTeardownSpec.cs @@ -0,0 +1,56 @@ +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; + +public sealed class Http3ConnectionErrorTeardownSpec +{ + private static Http3ConnectionOptions DefaultConnectionOptions() => new() + { + Limits = new ResolvedServerLimits( + MaxRequestBodySize: 30 * 1024 * 1024, + KeepAliveTimeout: TimeSpan.FromSeconds(130), + RequestHeadersTimeout: TimeSpan.FromSeconds(30), + MinRequestBodyDataRate: 240, + MinRequestBodyDataRateGracePeriod: TimeSpan.FromSeconds(5), + MinResponseDataRate: 240, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(5)), + MaxConcurrentStreams = 100, + MaxHeaderListSize = 32 * 1024, + MaxHeaderCount = 100, + QpackMaxTableCapacity = 0, + QpackBlockedStreams = 0, + MaxResponseBufferSize = 64 * 1024, + ResponseBodyChunkSize = 16 * 1024, + BodyConsumptionTimeout = TimeSpan.FromSeconds(30), + UseHuffman = true, + }; + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9204-2.2")] + public void Qpack_decode_error_should_request_connection_completion() + { + var ops = new FakeServerOps(); + var sm = new Http3ServerSessionManager(DefaultConnectionOptions(), ops); + + const long streamId = 0; + // HEADERS frame whose QPACK field section indexes a static-table entry far out of range: + // 2-byte field-section prefix (RIC=0, Base=0) + indexed-static line 0xFF + varint(137) -> index 200. + var headerBlock = new byte[] { 0x00, 0x00, 0xFF, 0x89, 0x01 }; + var frame = new HeadersFrame(headerBlock); + var buf = new byte[frame.SerializedSize]; + var span = buf.AsSpan(); + frame.WriteTo(ref span); + + sm.DecodeClientData(new ServerStreamAccepted(StreamTarget.FromId(streamId), StreamDirection.Bidirectional)); + var transport = TransportBuffer.Rent(buf.Length); + buf.CopyTo(transport.FullMemory.Span); + transport.Length = buf.Length; + sm.DecodeClientData(new MultiplexedData(transport, streamId)); + + Assert.True(sm.ShouldComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3CriticalStreamsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3CriticalStreamsSpec.cs index eae39c1dd..c5de0dd5f 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3CriticalStreamsSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3CriticalStreamsSpec.cs @@ -1,19 +1,37 @@ using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http3; -using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; using TurboHTTP.Tests.Shared; namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; public sealed class Http3CriticalStreamsSpec { + private static Http3ConnectionOptions DefaultConnectionOptions() => new() + { + Limits = new ResolvedServerLimits( + MaxRequestBodySize: 30 * 1024 * 1024, + KeepAliveTimeout: TimeSpan.FromSeconds(130), + RequestHeadersTimeout: TimeSpan.FromSeconds(30), + MinRequestBodyDataRate: 240, + MinRequestBodyDataRateGracePeriod: TimeSpan.FromSeconds(5), + MinResponseDataRate: 240, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(5)), + MaxConcurrentStreams = 100, + MaxHeaderListSize = 32 * 1024, + MaxHeaderCount = 100, + QpackMaxTableCapacity = 0, + QpackBlockedStreams = 0, + MaxResponseBufferSize = 64 * 1024, + ResponseBodyChunkSize = 16 * 1024, + BodyConsumptionTimeout = TimeSpan.FromSeconds(30), + UseHuffman = true, + }; private static Http3ServerSessionManager CreateSM(FakeServerOps ops) { - var enc = new Http3ServerEncoderOptions { QpackMaxTableCapacity = 0 }; - var dec = new Http3ServerDecoderOptions { MaxConcurrentStreams = 100 }; - return new Http3ServerSessionManager(enc, dec, ops); + return new Http3ServerSessionManager(DefaultConnectionOptions(), ops); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3DataRateViolationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3DataRateViolationSpec.cs new file mode 100644 index 000000000..06a134013 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3DataRateViolationSpec.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Time.Testing; +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Qpack; +using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; + +public sealed class Http3DataRateViolationSpec +{ + private static byte[] BuildRequest(string method, string path) + { + var tableSync = new QpackTableSync(0, 0, 0, 0); + var headers = new List<(string, string)> + { + (":method", method), + (":path", path), + (":scheme", "https"), + (":authority", "localhost"), + }; + var headerBlock = tableSync.Encoder.Encode(headers); + var frame = new HeadersFrame(headerBlock); + var buf = new byte[frame.SerializedSize]; + var span = buf.AsSpan(); + frame.WriteTo(ref span); + return buf; + } + + private static byte[] BuildDataFrameBytes(int size) + { + using var owner = System.Buffers.MemoryPool.Shared.Rent(size); + var df = new DataFrame(owner, size); + var buf = new byte[df.SerializedSize]; + var span = buf.AsSpan(); + df.WriteTo(ref span); + return buf; + } + + private static Http3ConnectionOptions OptionsWithRequestRate(double minRate, TimeSpan grace) => new() + { + Limits = new ResolvedServerLimits( + MaxRequestBodySize: 30 * 1024 * 1024, + KeepAliveTimeout: TimeSpan.FromSeconds(130), + RequestHeadersTimeout: TimeSpan.FromSeconds(30), + MinRequestBodyDataRate: minRate, + MinRequestBodyDataRateGracePeriod: grace, + MinResponseDataRate: 0, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(5)), + MaxConcurrentStreams = 100, + MaxHeaderListSize = 32 * 1024, + MaxHeaderCount = 100, + QpackMaxTableCapacity = 0, + QpackBlockedStreams = 0, + MaxResponseBufferSize = 64 * 1024, + ResponseBodyChunkSize = 16 * 1024, + BodyConsumptionTimeout = TimeSpan.FromSeconds(30), + UseHuffman = true, + }; + + private static void Send(Http3ServerSessionManager sm, long streamId, byte[] bytes) + { + var buffer = TransportBuffer.Rent(bytes.Length); + bytes.CopyTo(buffer.FullMemory.Span); + buffer.Length = bytes.Length; + sm.DecodeClientData(new MultiplexedData(buffer, streamId)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void Slow_request_body_should_reset_stream_after_grace_with_injected_clock() + { + var clock = new FakeTimeProvider(); + var ops = new FakeServerOps(); + var sm = new Http3ServerSessionManager(OptionsWithRequestRate(1000, TimeSpan.FromSeconds(1)), ops, clock); + + const long streamId = 4; + + sm.DecodeClientData(new ServerStreamAccepted(StreamTarget.FromId(streamId), StreamDirection.Bidirectional)); + Send(sm, streamId, BuildRequest("POST", "/upload")); + // A tiny DATA frame arrives, then the upload stalls (no StreamReadCompleted). + Send(sm, streamId, BuildDataFrameBytes(5)); + + clock.Advance(TimeSpan.FromMilliseconds(600)); + sm.CheckDataRates(); + Assert.DoesNotContain(ops.Outbound, o => o is ResetStream); + + // 5 bytes over 1700ms = ~2.9 bytes/sec << 1000; grace (1s) expired → ResetStream. + clock.Advance(TimeSpan.FromMilliseconds(1100)); + sm.CheckDataRates(); + Assert.Contains(ops.Outbound, o => o is ResetStream); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3HeadersTimerLeakSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3HeadersTimerLeakSpec.cs new file mode 100644 index 000000000..a8d3b36c8 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3HeadersTimerLeakSpec.cs @@ -0,0 +1,184 @@ +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Qpack; +using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; + +/// +/// Regression tests for the HeadersTimeout timer leak in Http3ServerSessionManager.CloseStream(). +/// Previously, CloseStream() cancelled BodyConsumptionTimerKey but omitted HeadersTimeoutTimerKey, +/// leaving the headers-timeout:<id> timer armed after the stream was closed. +/// +public sealed class Http3HeadersTimerLeakSpec +{ + private static Http3ConnectionOptions DefaultConnectionOptions() => new() + { + Limits = new ResolvedServerLimits( + MaxRequestBodySize: 30 * 1024 * 1024, + KeepAliveTimeout: TimeSpan.FromSeconds(130), + RequestHeadersTimeout: TimeSpan.FromSeconds(30), + MinRequestBodyDataRate: 240, + MinRequestBodyDataRateGracePeriod: TimeSpan.FromSeconds(5), + MinResponseDataRate: 240, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(5)), + MaxConcurrentStreams = 100, + MaxHeaderListSize = 32 * 1024, + MaxHeaderCount = 100, + QpackMaxTableCapacity = 0, + QpackBlockedStreams = 0, + MaxResponseBufferSize = 64 * 1024, + ResponseBodyChunkSize = 16 * 1024, + BodyConsumptionTimeout = TimeSpan.FromSeconds(30), + UseHuffman = false, + }; + + private static byte[] BuildHeadersFrame(string method = "GET", string path = "/") + { + var tableSync = new QpackTableSync(0, 0, 0, 0); + var headers = new List<(string, string)> + { + (":method", method), + (":path", path), + (":scheme", "https"), + (":authority", "localhost"), + }; + var headerBlock = tableSync.Encoder.Encode(headers); + var frame = new HeadersFrame(headerBlock); + var buf = new byte[frame.SerializedSize]; + var span = buf.AsSpan(); + frame.WriteTo(ref span); + return buf; + } + + private static Http3ServerSessionManager CreateSm(FakeServerOps ops) + { + return new Http3ServerSessionManager(DefaultConnectionOptions(), ops); + } + + private static void OpenAndFlushStream(Http3ServerSessionManager sm, long streamId, + string method = "GET", string path = "/") + { + var headersBytes = BuildHeadersFrame(method, path); + + sm.DecodeClientData(new ServerStreamAccepted( + StreamTarget.FromId(streamId), + StreamDirection.Bidirectional)); + + var buffer = TransportBuffer.Rent(headersBytes.Length); + headersBytes.CopyTo(buffer.FullMemory.Span); + buffer.Length = headersBytes.Length; + sm.DecodeClientData(new MultiplexedData(buffer, streamId)); + + // StreamReadCompleted makes the stream fully registered + sm.DecodeClientData(new StreamReadCompleted(StreamTarget.FromId(streamId))); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void EmitRstStream_should_cancel_headers_timeout_timer() + { + // Regression: CloseStream() previously did NOT cancel HeadersTimeoutTimerKey. + // When the server emits RST_STREAM to abort a stream, the headers-timeout: + // timer was left armed, leaking a timer handle. + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + + const long streamId = 4; + OpenAndFlushStream(sm, streamId); + + Assert.Single(ops.Requests); + ops.CancelledTimers.Clear(); + + // Server RST_STREAM — CloseStream must cancel the headers-timeout timer + sm.EmitRstStream(streamId, ErrorCode.GeneralProtocolError); + + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("headers-timeout:" + streamId)); + Assert.Equal(0, sm.ActiveStreamCount); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void EmitRstStream_should_cancel_both_body_and_headers_timers() + { + // Combined guard: both BodyConsumptionTimerKey and HeadersTimeoutTimerKey must be + // cancelled — protects against partial-fix regressions. + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + + const long streamId = 8; + OpenAndFlushStream(sm, streamId); + + Assert.Single(ops.Requests); + ops.CancelledTimers.Clear(); + + sm.EmitRstStream(streamId, ErrorCode.GeneralProtocolError); + + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("headers-timeout:" + streamId)); + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("body-consumption:" + streamId)); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void CloseStream_on_multiple_streams_should_cancel_respective_timer_keys() + { + // Each stream's CloseStream call must cancel that stream's own timer keys, + // not a shared key — verifies per-stream key naming. + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + + const long streamId4 = 4; + const long streamId8 = 8; + + OpenAndFlushStream(sm, streamId4); + OpenAndFlushStream(sm, streamId8, "POST", "/upload"); + + Assert.Equal(2, ops.Requests.Count); + ops.CancelledTimers.Clear(); + + sm.EmitRstStream(streamId4, ErrorCode.GeneralProtocolError); + Assert.Contains(ops.CancelledTimers, name => name == "headers-timeout:" + streamId4); + Assert.DoesNotContain(ops.CancelledTimers, name => name == "headers-timeout:" + streamId8); + + ops.CancelledTimers.Clear(); + + sm.EmitRstStream(streamId8, ErrorCode.GeneralProtocolError); + Assert.Contains(ops.CancelledTimers, name => name == "headers-timeout:" + streamId8); + Assert.DoesNotContain(ops.CancelledTimers, name => name == "headers-timeout:" + streamId4); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-4.1")] + public void FlushPendingRequest_should_cancel_headers_timeout_timer() + { + // FlushPendingRequest is the path for StreamReadCompleted and StreamClosed events. + // It must also cancel the headers-timeout timer when finalizing a stream. + var ops = new FakeServerOps(); + var sm = CreateSm(ops); + + const long streamId = 12; + var headersBytes = BuildHeadersFrame("GET", "/resource"); + + sm.DecodeClientData(new ServerStreamAccepted( + StreamTarget.FromId(streamId), + StreamDirection.Bidirectional)); + + var buffer = TransportBuffer.Rent(headersBytes.Length); + headersBytes.CopyTo(buffer.FullMemory.Span); + buffer.Length = headersBytes.Length; + sm.DecodeClientData(new MultiplexedData(buffer, streamId)); + + // Do NOT call StreamReadCompleted yet — stream is pending + Assert.Empty(ops.Requests); + + ops.CancelledTimers.Clear(); + + // StreamReadCompleted triggers FlushPendingRequest → OnCancelTimer for both keys + sm.DecodeClientData(new StreamReadCompleted(StreamTarget.FromId(streamId))); + + Assert.Contains(ops.CancelledTimers, name => name.StartsWith("headers-timeout:" + streamId)); + Assert.Single(ops.Requests); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3KeepAliveCloseSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3KeepAliveCloseSpec.cs new file mode 100644 index 000000000..85c78f9de --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3KeepAliveCloseSpec.cs @@ -0,0 +1,21 @@ +using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; + +public sealed class Http3KeepAliveCloseSpec +{ + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-5.1")] + public void OnTimerFired_should_set_ShouldComplete_on_keepalive_timeout() + { + var ops = new FakeServerOps(); + var sm = new Http3ServerStateMachine(new TurboServerOptions().ToHttp3Options(), ops); + sm.PreStart(); + + sm.OnTimerFired("keep-alive-timeout"); + + Assert.True(sm.ShouldComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3RapidResetSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3RapidResetSpec.cs new file mode 100644 index 000000000..9f884af27 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3RapidResetSpec.cs @@ -0,0 +1,83 @@ +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; + +public sealed class Http3RapidResetSpec +{ + private static Http3ConnectionOptions OptionsWithResetBudget(int budget) => new() + { + Limits = new ResolvedServerLimits( + MaxRequestBodySize: 30 * 1024 * 1024, + KeepAliveTimeout: TimeSpan.FromSeconds(130), + RequestHeadersTimeout: TimeSpan.FromSeconds(30), + MinRequestBodyDataRate: 240, + MinRequestBodyDataRateGracePeriod: TimeSpan.FromSeconds(5), + MinResponseDataRate: 240, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(5), + MaxResetStreamsPerWindow: budget), + MaxConcurrentStreams = 100, + MaxHeaderListSize = 32 * 1024, + MaxHeaderCount = 100, + QpackMaxTableCapacity = 0, + QpackBlockedStreams = 0, + MaxResponseBufferSize = 64 * 1024, + ResponseBodyChunkSize = 16 * 1024, + BodyConsumptionTimeout = TimeSpan.FromSeconds(30), + UseHuffman = true, + }; + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-8.1")] + public void Excessive_stream_resets_should_request_connection_completion() + { + // CVE-2023-44487 (Rapid Reset), HTTP/3 variant: a client that opens-and-aborts request streams + // faster than the budget must be cut off; MaxConcurrentStreams never saturates under this attack. + // A QUIC RESET_STREAM surfaces as StreamClosed(id, DisconnectReason.Error). + var ops = new FakeServerOps(); + var sm = new Http3ServerSessionManager(OptionsWithResetBudget(5), ops); + + for (var i = 0; i < 6; i++) + { + var streamId = i * 4L; // client-initiated bidirectional stream IDs + sm.DecodeClientData(new StreamClosed(StreamTarget.FromId(streamId), DisconnectReason.Error)); + } + + Assert.True(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-8.1")] + public void Resets_below_threshold_should_not_terminate_the_connection() + { + var ops = new FakeServerOps(); + var sm = new Http3ServerSessionManager(OptionsWithResetBudget(5), ops); + + for (var i = 0; i < 4; i++) + { + var streamId = i * 4L; + sm.DecodeClientData(new StreamClosed(StreamTarget.FromId(streamId), DisconnectReason.Error)); + } + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-8.1")] + public void Graceful_stream_completion_should_not_count_as_a_reset() + { + // StreamReadCompleted is a normal FIN, not an abort — it must never trip the reset budget. + var ops = new FakeServerOps(); + var sm = new Http3ServerSessionManager(OptionsWithResetBudget(5), ops); + + for (var i = 0; i < 20; i++) + { + var streamId = i * 4L; + sm.DecodeClientData(new StreamReadCompleted(StreamTarget.FromId(streamId))); + } + + Assert.False(sm.ShouldComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3SettingsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3SettingsSpec.cs new file mode 100644 index 000000000..57b14c4c1 --- /dev/null +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3SettingsSpec.cs @@ -0,0 +1,59 @@ +using Servus.Akka.Transport; +using TurboHTTP.Protocol.Syntax.Http3; +using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; + +public sealed class Http3SettingsSpec +{ + private static byte[] BuildSettingsFrame(params (long Id, long Value)[] parameters) + { + var frame = new SettingsFrame(parameters); + var buf = new byte[frame.SerializedSize]; + var span = buf.AsSpan(); + frame.WriteTo(ref span); + return buf; + } + + private static MultiplexedData WrapAsControlStream(byte[] data) + { + var buffer = TransportBuffer.Rent(data.Length); + data.CopyTo(buffer.FullMemory.Span); + buffer.Length = data.Length; + return new MultiplexedData(buffer, CriticalStreamId.ControlId); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-7.2.4")] + public void DecodeClientData_should_accept_first_SETTINGS_frame() + { + var ops = new FakeServerOps(); + var sm = new Http3ServerSessionManager(new TurboServerOptions().ToHttp3Options(), ops); + sm.PreStart(); + + var settingsData = BuildSettingsFrame(); + sm.DecodeClientData(WrapAsControlStream(settingsData)); + + Assert.False(sm.ShouldComplete); + } + + [Fact(Timeout = 5000)] + [Trait("RFC", "RFC9114-7.2.4")] + public void DecodeClientData_should_reject_second_SETTINGS_frame() + { + var ops = new FakeServerOps(); + var sm = new Http3ServerSessionManager(new TurboServerOptions().ToHttp3Options(), ops); + sm.PreStart(); + + var settings1 = BuildSettingsFrame(); + sm.DecodeClientData(WrapAsControlStream(settings1)); + Assert.False(sm.ShouldComplete); + + var settings2 = BuildSettingsFrame(); + sm.DecodeClientData(WrapAsControlStream(settings2)); + + Assert.True(sm.ShouldComplete); + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3StreamLifecycleSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3StreamLifecycleSpec.cs index bccfcb689..7791332aa 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3StreamLifecycleSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Server/SessionManager/Http3StreamLifecycleSpec.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http3; -using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Syntax.Http3.Server; +using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Tests.Shared; @@ -11,6 +11,27 @@ namespace TurboHTTP.Tests.Protocol.Syntax.Http3.Server.SessionManager; public sealed class Http3StreamLifecycleSpec { + private static Http3ConnectionOptions DefaultConnectionOptions() => new() + { + Limits = new ResolvedServerLimits( + MaxRequestBodySize: 30 * 1024 * 1024, + KeepAliveTimeout: TimeSpan.FromSeconds(130), + RequestHeadersTimeout: TimeSpan.FromSeconds(30), + MinRequestBodyDataRate: 240, + MinRequestBodyDataRateGracePeriod: TimeSpan.FromSeconds(5), + MinResponseDataRate: 240, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(5)), + MaxConcurrentStreams = 100, + MaxHeaderListSize = 32 * 1024, + MaxHeaderCount = 100, + QpackMaxTableCapacity = 0, + QpackBlockedStreams = 0, + MaxResponseBufferSize = 64 * 1024, + ResponseBodyChunkSize = 16 * 1024, + BodyConsumptionTimeout = TimeSpan.FromSeconds(30), + UseHuffman = true, + }; + private static IFeatureCollection CreateResponseContext(long streamId = 999) { var features = new TurboFeatureCollection(); @@ -57,9 +78,7 @@ private static void SendRequest(Http3ServerSessionManager sm, long streamId, str private static Http3ServerSessionManager CreateSM(FakeServerOps ops) { - var enc = new Http3ServerEncoderOptions { QpackMaxTableCapacity = 0 }; - var dec = new Http3ServerDecoderOptions { MaxConcurrentStreams = 100 }; - return new Http3ServerSessionManager(enc, dec, ops); + return new Http3ServerSessionManager(DefaultConnectionOptions(), ops); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/SharedHttpOptionsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/SharedHttpOptionsSpec.cs deleted file mode 100644 index cdd93b6f0..000000000 --- a/src/TurboHTTP.Tests/Protocol/Syntax/SharedHttpOptionsSpec.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Buffers; -using TurboHTTP.Protocol.Syntax; - -namespace TurboHTTP.Tests.Protocol.Syntax; - -public sealed class SharedHttpOptionsSpec -{ - [Fact(Timeout = 5000)] - public void Default_should_provide_sensible_values() - { - var d = SharedHttpOptions.Default; - Assert.Equal(64 * 1024L, d.StreamingThreshold); - Assert.Equal(4 * 1024 * 1024L, d.MaxBufferedBodySize); - Assert.Null(d.MaxStreamedBodySize); - Assert.Equal(32 * 1024, d.MaxHeaderBytes); - Assert.Equal(100, d.MaxHeaderCount); - Assert.Equal(8 * 1024, d.HeaderLineMaxLength); - Assert.Equal(8 * 1024, d.RequestLineMaxLength); - Assert.False(d.AllowObsFold); - Assert.Same(MemoryPool.Shared, d.BufferPool); - } - - [Fact(Timeout = 5000)] - public void Validate_should_pass_for_default() - { - SharedHttpOptions.Default.Validate(); - } - - [Theory(Timeout = 5000)] - [InlineData(-1)] - [InlineData(-100)] - public void Validate_should_reject_negative_StreamingThreshold(long bad) - { - var opts = SharedHttpOptions.Default with { StreamingThreshold = bad }; - var ex = Assert.Throws(opts.Validate); - Assert.Contains("StreamingThreshold", ex.Message); - } - - [Fact(Timeout = 5000)] - public void Validate_should_reject_when_MaxBufferedBodySize_below_StreamingThreshold() - { - var opts = SharedHttpOptions.Default with - { - StreamingThreshold = 100, - MaxBufferedBodySize = 50, - }; - var ex = Assert.Throws(opts.Validate); - Assert.Contains("MaxBufferedBodySize", ex.Message); - } - - [Fact(Timeout = 5000)] - public void Validate_should_reject_when_MaxHeaderCount_zero() - { - var opts = SharedHttpOptions.Default with { MaxHeaderCount = 0 }; - Assert.Throws(opts.Validate); - } - - [Fact(Timeout = 5000)] - public void With_should_create_modified_copy_without_mutation() - { - var d = SharedHttpOptions.Default; - var modified = d with { StreamingThreshold = 1024 }; - Assert.Equal(64 * 1024L, d.StreamingThreshold); - Assert.Equal(1024, modified.StreamingThreshold); - } -} \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Server/Context/Features/TurboHttpRequestLifetimeFeatureSpec.cs b/src/TurboHTTP.Tests/Server/Context/Features/TurboHttpRequestLifetimeFeatureSpec.cs new file mode 100644 index 000000000..c541ece46 --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Context/Features/TurboHttpRequestLifetimeFeatureSpec.cs @@ -0,0 +1,77 @@ +using TurboHTTP.Server.Context.Features; + +namespace TurboHTTP.Tests.Server.Context.Features; + +public sealed class TurboHttpRequestLifetimeFeatureSpec +{ + [Fact(Timeout = 5000)] + public void RequestAborted_should_be_cancellable_token() + { + var feature = new TurboHttpRequestLifetimeFeature(); + + Assert.True(feature.RequestAborted.CanBeCanceled); + Assert.False(feature.RequestAborted.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void Abort_should_cancel_RequestAborted_token() + { + var feature = new TurboHttpRequestLifetimeFeature(); + var token = feature.RequestAborted; + + feature.Abort(); + + Assert.True(token.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void Abort_should_trigger_registered_callbacks() + { + var feature = new TurboHttpRequestLifetimeFeature(); + var called = false; + feature.RequestAborted.Register(() => called = true); + + feature.Abort(); + + Assert.True(called); + } + + [Fact(Timeout = 5000)] + public void RequestAborted_setter_should_link_to_external_token() + { + var feature = new TurboHttpRequestLifetimeFeature(); + using var externalCts = new CancellationTokenSource(); + + feature.RequestAborted = externalCts.Token; + + Assert.False(feature.RequestAborted.IsCancellationRequested); + externalCts.Cancel(); + Assert.True(feature.RequestAborted.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void Abort_should_cancel_even_when_linked_to_external_token() + { + var feature = new TurboHttpRequestLifetimeFeature(); + using var externalCts = new CancellationTokenSource(); + feature.RequestAborted = externalCts.Token; + + feature.Abort(); + + Assert.True(feature.RequestAborted.IsCancellationRequested); + Assert.False(externalCts.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void Reset_should_provide_fresh_uncancelled_token() + { + var feature = new TurboHttpRequestLifetimeFeature(); + feature.Abort(); + Assert.True(feature.RequestAborted.IsCancellationRequested); + + feature.Reset(); + + Assert.True(feature.RequestAborted.CanBeCanceled); + Assert.False(feature.RequestAborted.IsCancellationRequested); + } +} diff --git a/src/TurboHTTP.Tests/Server/ContextPoolingSpec.cs b/src/TurboHTTP.Tests/Server/ContextPoolingSpec.cs index 0111c6685..75b442d75 100644 --- a/src/TurboHTTP.Tests/Server/ContextPoolingSpec.cs +++ b/src/TurboHTTP.Tests/Server/ContextPoolingSpec.cs @@ -101,4 +101,128 @@ public void FeatureCollectionFactory_Return_stores_context_in_pool() Assert.NotNull(ctx2); } + + [Fact(Timeout = 5000)] + public void TurboHttpRequestBodyDetectionFeature_Reset_should_update_CanHaveBody() + { + var feature = new TurboHttpRequestBodyDetectionFeature(true); + Assert.True(feature.CanHaveBody); + + feature.Reset(false); + + Assert.False(feature.CanHaveBody); + } + + [Fact(Timeout = 5000)] + public void TurboHttpRequestIdentifierFeature_Reset_should_clear_trace_identifier() + { + var feature = new TurboHttpRequestIdentifierFeature(); + var original = feature.TraceIdentifier; + + feature.Reset(); + var afterReset = feature.TraceIdentifier; + + Assert.NotEqual(original, afterReset); + } + + [Fact(Timeout = 5000)] + public void TurboHttpMaxRequestBodySizeFeature_Reset_should_restore_defaults() + { + var feature = new TurboHttpMaxRequestBodySizeFeature + { + IsReadOnly = true, + MaxRequestBodySize = 999 + }; + + feature.Reset(42); + + Assert.False(feature.IsReadOnly); + Assert.Equal(42, feature.MaxRequestBodySize); + } + + [Fact(Timeout = 5000)] + public void TurboHttpBodyControlFeature_Reset_should_clear_AllowSynchronousIO() + { + var feature = new TurboHttpBodyControlFeature { AllowSynchronousIO = true }; + + feature.Reset(); + + Assert.False(feature.AllowSynchronousIO); + } + + [Fact(Timeout = 5000)] + public void TurboHttpRequestLifetimeFeature_Reset_should_provide_fresh_non_cancelled_token() + { + var feature = new TurboHttpRequestLifetimeFeature(); + feature.Abort(); + Assert.True(feature.RequestAborted.IsCancellationRequested); + + feature.Reset(); + + Assert.False(feature.RequestAborted.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void TurboHttpRequestLifetimeFeature_Reset_without_cancel_should_not_throw() + { + var feature = new TurboHttpRequestLifetimeFeature(); + var token1 = feature.RequestAborted; + Assert.False(token1.IsCancellationRequested); + + feature.Reset(); + + Assert.False(feature.RequestAborted.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void FeatureCollectionFactory_should_reuse_response_feature_from_pool() + { + var ctx = FeatureCollectionFactory.Create( + new TurboHttpRequestFeature(), hasBody: false); + var originalResponse = ctx.Get(); + originalResponse!.StatusCode = 404; + + FeatureCollectionFactory.Return(ctx); + + var ctx2 = FeatureCollectionFactory.Create( + new TurboHttpRequestFeature(), hasBody: true); + var reusedResponse = ctx2.Get(); + + Assert.Same(originalResponse, reusedResponse); + Assert.Equal(200, reusedResponse!.StatusCode); + } + + [Fact(Timeout = 5000)] + public void FeatureCollectionFactory_should_reuse_lifetime_feature_from_pool() + { + var ctx = FeatureCollectionFactory.Create( + new TurboHttpRequestFeature(), hasBody: false); + var originalLifetime = ctx.Get(); + + FeatureCollectionFactory.Return(ctx); + + var ctx2 = FeatureCollectionFactory.Create( + new TurboHttpRequestFeature(), hasBody: false); + var reusedLifetime = ctx2.Get(); + + Assert.Same(originalLifetime, reusedLifetime); + Assert.False(reusedLifetime!.RequestAborted.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void FeatureCollectionFactory_should_recycle_response_body_feature() + { + var ctx = FeatureCollectionFactory.Create( + new TurboHttpRequestFeature(), hasBody: false); + var originalBody = ctx.Get(); + + FeatureCollectionFactory.Return(ctx); + + var ctx2 = FeatureCollectionFactory.Create( + new TurboHttpRequestFeature(), hasBody: false); + var recycledBody = ctx2.Get(); + + Assert.Same(originalBody, recycledBody); + Assert.False(((TurboHttpResponseBodyFeature)recycledBody!).HasStarted); + } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Server/Options/ProtocolOptionsNullableOverrideSpec.cs b/src/TurboHTTP.Tests/Server/Options/ProtocolOptionsNullableOverrideSpec.cs new file mode 100644 index 000000000..190054a0f --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Options/ProtocolOptionsNullableOverrideSpec.cs @@ -0,0 +1,37 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server.Options; + +public sealed class ProtocolOptionsNullableOverrideSpec +{ + [Fact(Timeout = 5000)] + public void Shared_overrides_should_default_to_null() + { + var h1 = new Http1ServerOptions(); + var h2 = new Http2ServerOptions(); + var h3 = new Http3ServerOptions(); + + Assert.Null(h1.MaxRequestBodySize); + Assert.Null(h1.MinRequestBodyDataRate); + Assert.Null(h1.MinResponseDataRate); + Assert.Null(h2.KeepAliveTimeout); + Assert.Null(h2.MinRequestBodyDataRate); + Assert.Null(h2.MinResponseDataRate); + Assert.Null(h3.MaxRequestBodySize); + Assert.Null(h3.KeepAliveTimeout); + Assert.Null(h3.MinResponseDataRate); + } + + [Fact(Timeout = 5000)] + public void Setting_overrides_should_compile_via_implicit_conversion() + { + var h2 = new Http2ServerOptions + { + KeepAliveTimeout = TimeSpan.FromSeconds(60), + MinRequestBodyDataRate = 240, + }; + + Assert.Equal(TimeSpan.FromSeconds(60), h2.KeepAliveTimeout); + Assert.Equal(240d, h2.MinRequestBodyDataRate); + } +} diff --git a/src/TurboHTTP.Tests/Server/Options/ResolvedServerLimitsSpec.cs b/src/TurboHTTP.Tests/Server/Options/ResolvedServerLimitsSpec.cs new file mode 100644 index 000000000..62a31b59e --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Options/ResolvedServerLimitsSpec.cs @@ -0,0 +1,22 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server.Options; + +public sealed class ResolvedServerLimitsSpec +{ + [Fact(Timeout = 5000)] + public void Should_hold_all_six_resolved_values() + { + var r = new ResolvedServerLimits( + MaxRequestBodySize: 123, + KeepAliveTimeout: TimeSpan.FromSeconds(10), + RequestHeadersTimeout: TimeSpan.FromSeconds(20), + MinRequestBodyDataRate: 1, + MinRequestBodyDataRateGracePeriod: TimeSpan.FromSeconds(3), + MinResponseDataRate: 2, + MinResponseDataRateGracePeriod: TimeSpan.FromSeconds(4)); + + Assert.Equal(123, r.MaxRequestBodySize); + Assert.Equal(2, r.MinResponseDataRate); + } +} diff --git a/src/TurboHTTP.Tests/Server/Options/ServerOptionsProjectionsSpec.cs b/src/TurboHTTP.Tests/Server/Options/ServerOptionsProjectionsSpec.cs new file mode 100644 index 000000000..e4774a672 --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Options/ServerOptionsProjectionsSpec.cs @@ -0,0 +1,276 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server.Options; + +public sealed class ServerOptionsProjectionsSpec +{ + [Fact(Timeout = 5000)] + public void Override_should_win_over_limits() + { + var o = new TurboServerOptions + { + Http2 = + { + MaxRequestBodySize = 999, + KeepAliveTimeout = TimeSpan.FromSeconds(7) + } + }; + + var eff = o.ToHttp2Options(); + + Assert.Equal(999, eff.Limits.MaxRequestBodySize); + Assert.Equal(TimeSpan.FromSeconds(7), eff.Limits.KeepAliveTimeout); + } + + [Fact(Timeout = 5000)] + public void Null_override_should_inherit_limits() + { + var o = new TurboServerOptions(); + + var eff = o.ToHttp2Options(); + + Assert.Equal(o.Limits.MaxRequestBodySize, eff.Limits.MaxRequestBodySize); + Assert.Equal(o.Limits.KeepAliveTimeout, eff.Limits.KeepAliveTimeout); + Assert.Equal(o.Limits.MinResponseDataRate, eff.Limits.MinResponseDataRate); + } + + [Fact(Timeout = 5000)] + public void Http3_body_override_should_now_be_honored() + { + var o = new TurboServerOptions + { + Http3 = + { + MaxRequestBodySize = 555 + } + }; + + Assert.Equal(555, o.ToHttp3Options().Limits.MaxRequestBodySize); + } + + [Fact(Timeout = 5000)] + public void ToRateMonitor_should_project_four_rate_fields() + { + var eff = new TurboServerOptions().ToHttp2Options(); + + var rate = eff.ToRateMonitor(); + + Assert.Equal(eff.Limits.MinRequestBodyDataRate, rate.MinRequestBodyDataRate); + Assert.Equal(eff.Limits.MinResponseDataRate, rate.MinResponseDataRate); + } + + [Fact(Timeout = 5000)] + public void Http1_chunk_extension_limit_should_flow_to_decoder_options() + { + var o = new TurboServerOptions + { + Http1 = + { + MaxChunkExtensionLength = 7 + } + }; + + var dec = o.ToHttp1Options().ToHttp11DecoderOptions(); + + Assert.Equal(7, dec.MaxChunkExtensionLength); + } + + [Fact(Timeout = 5000)] + public void Header_size_should_fall_back_to_global_total_when_protocol_unset() + { + var o = new TurboServerOptions + { + Limits = + { + MaxRequestHeadersTotalSize = 7777 + } + }; + + Assert.Equal(7777, o.ToHttp1Options().MaxHeaderListSize); + Assert.Equal(7777, o.ToHttp2Options().MaxHeaderListSize); + Assert.Equal(7777, o.ToHttp3Options().MaxHeaderListSize); + } + + [Fact(Timeout = 5000)] + public void Header_size_protocol_override_should_win_over_global_total() + { + var o = new TurboServerOptions + { + Limits = + { + MaxRequestHeadersTotalSize = 7777 + }, + Http2 = + { + MaxHeaderListSize = 999 + } + }; + + Assert.Equal(999, o.ToHttp2Options().MaxHeaderListSize); + } + + [Fact(Timeout = 5000)] + public void Http2_response_buffer_limit_should_flow_to_connection_options() + { + var o = new TurboServerOptions + { + Http2 = + { + MaxResponseBufferSize = 4321 + } + }; + + Assert.Equal(4321, o.ToHttp2Options().MaxResponseBufferSize); + } + + [Fact(Timeout = 5000)] + public void MaxRequestBodySize_default_should_match_kestrel() + { + var o = new TurboServerOptions(); + + Assert.Equal(30_000_000, o.Limits.MaxRequestBodySize); + } + + [Fact(Timeout = 5000)] + public void MaxResponseBufferSize_global_default_should_be_64_KiB() + { + var o = new TurboServerOptions(); + + Assert.Equal(64 * 1024, o.Limits.MaxResponseBufferSize); + } + + [Fact(Timeout = 5000)] + public void Http2_MaxResponseBufferSize_should_fall_back_to_global() + { + var o = new TurboServerOptions + { + Limits = { MaxResponseBufferSize = 99_999 } + }; + + Assert.Equal(99_999, o.ToHttp2Options().MaxResponseBufferSize); + } + + [Fact(Timeout = 5000)] + public void Http2_MaxResponseBufferSize_override_should_win() + { + var o = new TurboServerOptions + { + Limits = { MaxResponseBufferSize = 99_999 }, + Http2 = { MaxResponseBufferSize = 42 } + }; + + Assert.Equal(42, o.ToHttp2Options().MaxResponseBufferSize); + } + + [Fact(Timeout = 5000)] + public void Http3_MaxResponseBufferSize_should_fall_back_to_global() + { + var o = new TurboServerOptions + { + Limits = { MaxResponseBufferSize = 88_888 } + }; + + Assert.Equal(88_888, o.ToHttp3Options().MaxResponseBufferSize); + } + + [Fact(Timeout = 5000)] + public void Http3_MaxResponseBufferSize_override_should_win() + { + var o = new TurboServerOptions + { + Limits = { MaxResponseBufferSize = 88_888 }, + Http3 = { MaxResponseBufferSize = 77 } + }; + + Assert.Equal(77, o.ToHttp3Options().MaxResponseBufferSize); + } + + [Fact(Timeout = 5000)] + public void Http3_response_buffer_limit_should_flow_to_connection_options() + { + var o = new TurboServerOptions + { + Http3 = { MaxResponseBufferSize = 5678 } + }; + + Assert.Equal(5678, o.ToHttp3Options().MaxResponseBufferSize); + } + + [Fact(Timeout = 5000)] + public void MaxRequestBufferSize_default_should_be_1_MiB() + { + var o = new TurboServerOptions(); + + Assert.Equal(1024 * 1024, o.Limits.MaxRequestBufferSize); + } + + [Fact(Timeout = 5000)] + public void MaxOutboundCoalesceCount_default_should_be_32() + { + var o = new TurboServerOptions(); + + Assert.Equal(32, o.MaxOutboundCoalesceCount); + } + + [Fact(Timeout = 5000)] + public void AllowResponseHeaderCompression_default_should_be_true() + { + var o = new TurboServerOptions(); + + Assert.True(o.AllowResponseHeaderCompression); + } + + [Fact(Timeout = 5000)] + public void AllowResponseHeaderCompression_should_flow_to_h2_encoder_options() + { + var o = new TurboServerOptions { AllowResponseHeaderCompression = false }; + + var enc = o.ToHttp2Options().ToEncoderOptions(); + + Assert.False(enc.UseHuffman); + } + + [Fact(Timeout = 5000)] + public void AllowResponseHeaderCompression_should_flow_to_h3_encoder_options() + { + var o = new TurboServerOptions { AllowResponseHeaderCompression = false }; + + var enc = o.ToHttp3Options().ToEncoderOptions(); + + Assert.False(enc.UseHuffman); + } + + [Fact(Timeout = 5000)] + public void Http2_KeepAlivePingDelay_default_should_be_infinite() + { + var o = new TurboServerOptions(); + + Assert.Equal(Timeout.InfiniteTimeSpan, o.ToHttp2Options().KeepAlivePingDelay); + } + + [Fact(Timeout = 5000)] + public void Http2_KeepAlivePingTimeout_default_should_be_20s() + { + var o = new TurboServerOptions(); + + Assert.Equal(TimeSpan.FromSeconds(20), o.ToHttp2Options().KeepAlivePingTimeout); + } + + [Fact(Timeout = 5000)] + public void Http2_KeepAlivePing_custom_should_flow() + { + var o = new TurboServerOptions + { + Http2 = + { + KeepAlivePingDelay = TimeSpan.FromSeconds(15), + KeepAlivePingTimeout = TimeSpan.FromSeconds(5) + } + }; + + var h2 = o.ToHttp2Options(); + + Assert.Equal(TimeSpan.FromSeconds(15), h2.KeepAlivePingDelay); + Assert.Equal(TimeSpan.FromSeconds(5), h2.KeepAlivePingTimeout); + } +} diff --git a/src/TurboHTTP.Tests/Server/Options/TransportBufferOptionsSpec.cs b/src/TurboHTTP.Tests/Server/Options/TransportBufferOptionsSpec.cs new file mode 100644 index 000000000..3b535b9ac --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Options/TransportBufferOptionsSpec.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using Servus.Akka.Transport; +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server.Options; + +public sealed class TransportBufferOptionsSpec +{ + [Fact(Timeout = 5000)] + public void Tcp_partial_transport_override_should_fall_back_to_tcp_defaults_per_property() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5000, listen => + { + listen.Transport = new TransportBufferOptions + { + OutputPauseThreshold = 128 * 1024 + }; + }); + + var binding = Assert.Single(new EndpointResolver().Resolve(options)); + var tcp = Assert.IsType(binding.Options); + + Assert.Equal(128 * 1024, tcp.OutputPauseThreshold); + Assert.Equal(1024 * 1024, tcp.InputPauseThreshold); + Assert.Equal(512 * 1024, tcp.InputResumeThreshold); + Assert.Equal(32 * 1024, tcp.OutputResumeThreshold); + Assert.Equal(16 * 1024, tcp.MinimumSegmentSize); + } + + [Fact(Timeout = 5000)] + public void Quic_partial_transport_override_should_fall_back_to_quic_defaults_per_property() + { + using var cert = CreateSelfSignedCert(); + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5001, listen => + { + listen.Protocols = HttpProtocols.Http3; + listen.UseHttps(cert); + listen.Transport = new TransportBufferOptions + { + InputPauseThreshold = 256 * 1024 + }; + }); + + var binding = Assert.Single(new EndpointResolver().Resolve(options)); + var quic = Assert.IsType(binding.Options); + + Assert.Equal(256 * 1024, quic.InputPauseThreshold); + Assert.Equal(32 * 1024, quic.InputResumeThreshold); + Assert.Equal(64 * 1024, quic.OutputPauseThreshold); + Assert.Equal(32 * 1024, quic.OutputResumeThreshold); + Assert.Equal(4 * 1024, quic.MinimumSegmentSize); + } + + [Fact(Timeout = 5000)] + public void Null_transport_should_use_tcp_defaults() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5002); + + var binding = Assert.Single(new EndpointResolver().Resolve(options)); + var tcp = Assert.IsType(binding.Options); + + Assert.Equal(1024 * 1024, tcp.InputPauseThreshold); + Assert.Equal(512 * 1024, tcp.InputResumeThreshold); + Assert.Equal(64 * 1024, tcp.OutputPauseThreshold); + Assert.Equal(32 * 1024, tcp.OutputResumeThreshold); + Assert.Equal(16 * 1024, tcp.MinimumSegmentSize); + } + + [Fact(Timeout = 5000)] + public void Resolved_input_resume_above_pause_should_throw() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5003, listen => + { + listen.Transport = new TransportBufferOptions + { + InputResumeThreshold = 2 * 1024 * 1024 + }; + }); + + Assert.Throws(() => new EndpointResolver().Resolve(options)); + } + + [Fact(Timeout = 5000)] + public void Resolved_output_resume_above_pause_should_throw() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5004, listen => + { + listen.Transport = new TransportBufferOptions + { + OutputResumeThreshold = 128 * 1024 + }; + }); + + Assert.Throws(() => new EndpointResolver().Resolve(options)); + } + + private static X509Certificate2 CreateSelfSignedCert() + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var request = new CertificateRequest("CN=Test", rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } +} diff --git a/src/TurboHTTP.Tests/Server/Options/TurboServerLimitsDefaultsSpec.cs b/src/TurboHTTP.Tests/Server/Options/TurboServerLimitsDefaultsSpec.cs new file mode 100644 index 000000000..e3fbe2a7c --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Options/TurboServerLimitsDefaultsSpec.cs @@ -0,0 +1,20 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server.Options; + +public sealed class TurboServerLimitsDefaultsSpec +{ + [Fact(Timeout = 5000)] + public void Defaults_should_match_Kestrel_parity() + { + var limits = new TurboServerLimits(); + + Assert.Equal(30_000_000L, limits.MaxRequestBodySize); + Assert.Equal(TimeSpan.FromSeconds(130), limits.KeepAliveTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), limits.RequestHeadersTimeout); + Assert.Equal(240d, limits.MinRequestBodyDataRate); + Assert.Equal(TimeSpan.FromSeconds(5), limits.MinRequestBodyDataRateGracePeriod); + Assert.Equal(240d, limits.MinResponseDataRate); + Assert.Equal(TimeSpan.FromSeconds(5), limits.MinResponseDataRateGracePeriod); + } +} diff --git a/src/TurboHTTP.Tests/Server/Options/TurboServerOptionsValidationSpec.cs b/src/TurboHTTP.Tests/Server/Options/TurboServerOptionsValidationSpec.cs new file mode 100644 index 000000000..97dc185c9 --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Options/TurboServerOptionsValidationSpec.cs @@ -0,0 +1,83 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server.Options; + +public sealed class TurboServerOptionsValidationSpec +{ + [Fact(Timeout = 5000)] + public void Validate_should_accept_default_options() + { + var options = new TurboServerOptions(); + options.Validate(); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_negative_MaxRequestBodySize() + { + var options = new TurboServerOptions { Limits = { MaxRequestBodySize = -1 } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_zero_MaxRequestHeadersTotalSize() + { + var options = new TurboServerOptions { Limits = { MaxRequestHeadersTotalSize = 0 } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_zero_MaxRequestHeaderCount() + { + var options = new TurboServerOptions { Limits = { MaxRequestHeaderCount = 0 } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_negative_KeepAliveTimeout() + { + var options = new TurboServerOptions { Limits = { KeepAliveTimeout = TimeSpan.FromSeconds(-1) } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_zero_HandlerTimeout() + { + var options = new TurboServerOptions { HandlerTimeout = TimeSpan.Zero }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_H2_MaxFrameSize_below_RFC_minimum() + { + var options = new TurboServerOptions { Http2 = { MaxFrameSize = 1024 } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_H2_MaxFrameSize_above_RFC_maximum() + { + var options = new TurboServerOptions { Http2 = { MaxFrameSize = 16 * 1024 * 1024 + 1 } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_H2_InitialWindowSize_below_one() + { + var options = new TurboServerOptions { Http2 = { InitialStreamWindowSize = 0 } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_H2_MaxConcurrentStreams_below_one() + { + var options = new TurboServerOptions { Http2 = { MaxConcurrentStreams = 0 } }; + Assert.Throws(() => options.Validate()); + } + + [Fact(Timeout = 5000)] + public void Validate_should_reject_H3_MaxConcurrentStreams_below_one() + { + var options = new TurboServerOptions { Http3 = { MaxConcurrentStreams = 0 } }; + Assert.Throws(() => options.Validate()); + } +} diff --git a/src/TurboHTTP.Tests/Server/ServerContextFactorySpec.cs b/src/TurboHTTP.Tests/Server/ServerContextFactorySpec.cs index 099cf1b60..849b0a084 100644 --- a/src/TurboHTTP.Tests/Server/ServerContextFactorySpec.cs +++ b/src/TurboHTTP.Tests/Server/ServerContextFactorySpec.cs @@ -151,4 +151,19 @@ public void Create_should_set_body_control_feature_with_sync_io_disabled() Assert.NotNull(bodyControl); Assert.False(bodyControl.AllowSynchronousIO); } + + [Fact(Timeout = 5000)] + public void Return_should_reset_lifetime_feature_after_abort() + { + var requestFeature = new TurboHttpRequestFeature(); + var features = FeatureCollectionFactory.Create(requestFeature, hasBody: false); + + var lifetime = features.Get()!; + lifetime.Abort(); + Assert.True(lifetime.RequestAborted.IsCancellationRequested); + + FeatureCollectionFactory.Return(features); + + Assert.False(lifetime.RequestAborted.IsCancellationRequested); + } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Server/TurboServerLimitsSpec.cs b/src/TurboHTTP.Tests/Server/TurboServerLimitsSpec.cs new file mode 100644 index 000000000..b95676f7e --- /dev/null +++ b/src/TurboHTTP.Tests/Server/TurboServerLimitsSpec.cs @@ -0,0 +1,13 @@ +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server; + +public sealed class TurboServerLimitsSpec +{ + [Fact(Timeout = 5000)] + public void MaxConcurrentConnections_should_default_to_zero_meaning_unlimited() + { + var limits = new TurboServerLimits(); + Assert.Equal(0, limits.MaxConcurrentConnections); + } +} diff --git a/src/TurboHTTP.Tests/Streams/EngineSpec.cs b/src/TurboHTTP.Tests/Streams/EngineSpec.cs index 2912c0bee..e4597493b 100644 --- a/src/TurboHTTP.Tests/Streams/EngineSpec.cs +++ b/src/TurboHTTP.Tests/Streams/EngineSpec.cs @@ -51,8 +51,8 @@ public void Engine_should_use_provided_turbo_client_options() var descriptor = PipelineDescriptor.Empty; var options = new TurboClientOptions { - MaxEndpointSubstreams = 20, - Http1 = new Http1Options { MaxPipelineDepth = 2 } + MaxConcurrentEndpoints = 20, + Http1 = new Http1ClientOptions { MaxPipelineDepth = 2 } }; // Act diff --git a/src/TurboHTTP.Tests/Streams/Stages/Client/HandlerBidiStageSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Client/HandlerBidiStageSpec.cs index 1af6f8dd9..e321b0bbd 100644 --- a/src/TurboHTTP.Tests/Streams/Stages/Client/HandlerBidiStageSpec.cs +++ b/src/TurboHTTP.Tests/Streams/Stages/Client/HandlerBidiStageSpec.cs @@ -3,6 +3,7 @@ using Akka; using Akka.Streams; using Akka.Streams.Dsl; +using TurboHTTP.Internal; using TurboHTTP.Streams.Stages.Client; using TurboHTTP.Tests.Shared; @@ -271,6 +272,98 @@ public async Task HandlerBidiStage_should_flow_through_with_completion_when_mult } } + private sealed class ThrowOnUriRequestHandler : TurboHandler + { + private readonly string _marker; + private readonly Exception _toThrow; + + public ThrowOnUriRequestHandler(string marker, Exception toThrow) + { + _marker = marker; + _toThrow = toThrow; + } + + public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) + { + if (request.RequestUri!.AbsoluteUri.Contains(_marker)) + { + throw _toThrow; + } + + return request; + } + } + + private sealed class ThrowOnUriResponseHandler : TurboHandler + { + private readonly string _marker; + private readonly Exception _toThrow; + + public ThrowOnUriResponseHandler(string marker, Exception toThrow) + { + _marker = marker; + _toThrow = toThrow; + } + + public override HttpResponseMessage ProcessResponse(HttpRequestMessage original, HttpResponseMessage response) + { + if (original.RequestUri!.AbsoluteUri.Contains(_marker)) + { + throw _toThrow; + } + + return response; + } + } + + private static (HttpRequestMessage Request, ValueTask Pending) RequestWithPending(string uri) + { + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var pending = PendingRequest.Rent(); + request.Options.Set(OptionsKey.Key, pending); + request.Options.Set(OptionsKey.VersionKey, pending.Version); + return (request, pending.GetValueTask()); + } + + [Fact(Timeout = 10_000)] + public async Task HandlerBidiStage_should_fail_only_offending_request_and_keep_pipeline_alive() + { + var stage = new HandlerBidiStage( + new ThrowOnUriRequestHandler("boom", new InvalidOperationException("request boom")), 0); + + var good1 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/ok1"); + var (bad, badPending) = RequestWithPending("http://example.com/boom"); + var good2 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/ok2"); + + var results = await RunRequestAsync(stage, good1, bad, good2); + + Assert.Equal(2, results.Count); + Assert.Equal("http://example.com/ok1", results[0].RequestUri!.ToString()); + Assert.Equal("http://example.com/ok2", results[1].RequestUri!.ToString()); + + var ex = await Assert.ThrowsAsync(async () => await badPending); + Assert.Equal("request boom", ex.Message); + } + + [Fact(Timeout = 10_000)] + public async Task HandlerBidiStage_should_fail_only_offending_response_and_keep_pipeline_alive() + { + var stage = new HandlerBidiStage( + new ThrowOnUriResponseHandler("boom", new InvalidOperationException("response boom")), 0); + + var goodReq1 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/ok1"); + var (badReq, badPending) = RequestWithPending("http://example.com/boom"); + var goodReq2 = new HttpRequestMessage(HttpMethod.Get, "http://example.com/ok2"); + + var results = await RunResponseAsync(stage, + MakeResponse(goodReq1), MakeResponse(badReq), MakeResponse(goodReq2)); + + Assert.Equal(2, results.Count); + + var ex = await Assert.ThrowsAsync(async () => await badPending); + Assert.Equal("response boom", ex.Message); + } + [Fact(Timeout = 10_000)] public async Task HandlerBidiStage_should_flow_through_with_completion_when_multiple_responses_sent() { diff --git a/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherProxySpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherProxySpec.cs new file mode 100644 index 000000000..225c1d226 --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherProxySpec.cs @@ -0,0 +1,106 @@ +using System.Net; +using TurboHTTP.Client; +using TurboHTTP.Streams.Stages.Client; + +namespace TurboHTTP.Tests.Streams.Stages.Client; + +public sealed class RequestEnricherProxySpec +{ + private static TurboRequestOptions Options(bool useProxy = true, IWebProxy? proxy = null) + { + return new TurboRequestOptions( + BaseAddress: null, + DefaultRequestHeaders: new HttpRequestMessage().Headers, + DefaultRequestVersion: HttpVersion.Version11, + DefaultVersionPolicy: HttpVersionPolicy.RequestVersionOrLower, + Timeout: TimeSpan.FromSeconds(30), + UseProxy: useProxy, + Proxy: proxy); + } + + private static HttpRequestMessage Http3Request(HttpVersionPolicy policy = HttpVersionPolicy.RequestVersionOrLower) + { + return new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource") + { + Version = HttpVersion.Version30, + VersionPolicy = policy + }; + } + + [Fact(Timeout = 5000)] + public void Enrich_should_downgrade_http3_to_http2_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version20, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_throw_for_http3_exact_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + + Assert.Throws( + () => enricher.Enrich(Http3Request(HttpVersionPolicy.RequestVersionExact))); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_throw_for_http3_or_higher_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + + Assert.Throws( + () => enricher.Enrich(Http3Request(HttpVersionPolicy.RequestVersionOrHigher))); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_keep_http3_when_no_proxy_configured() + { + var enricher = new RequestEnricher(() => Options(proxy: null)); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version30, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_keep_http3_when_use_proxy_is_false() + { + var enricher = new RequestEnricher( + () => Options(useProxy: false, proxy: new WebProxy("http://proxy.local:8080"))); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version30, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_keep_http3_when_proxy_bypasses_host() + { + var proxy = new WebProxy("http://proxy.local:8080") + { + BypassList = [@"example\.com"] + }; + var enricher = new RequestEnricher(() => Options(proxy: proxy)); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version30, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_not_touch_http2_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource") + { + Version = HttpVersion.Version20 + }; + + var result = enricher.Enrich(request); + + Assert.Equal(HttpVersion.Version20, result.Version); + } +} diff --git a/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherTimeoutSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherTimeoutSpec.cs new file mode 100644 index 000000000..1376ef93c --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherTimeoutSpec.cs @@ -0,0 +1,112 @@ +using System.Net; +using TurboHTTP.Client; +using TurboHTTP.Internal; +using TurboHTTP.Streams.Stages.Client; + +namespace TurboHTTP.Tests.Streams.Stages.Client; + +public sealed class RequestEnricherTimeoutSpec +{ + private static TurboRequestOptions CreateOptions(TimeSpan timeout) + { + var msg = new HttpRequestMessage(); + return new TurboRequestOptions( + BaseAddress: new Uri("https://example.com"), + DefaultRequestHeaders: msg.Headers, + DefaultRequestVersion: HttpVersion.Version11, + DefaultVersionPolicy: HttpVersionPolicy.RequestVersionOrLower, + Timeout: timeout, + Credentials: null, + PreAuthenticate: false + ); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_set_cancellation_token_from_default_timeout() + { + var options = CreateOptions(TimeSpan.FromSeconds(5)); + var enricher = new RequestEnricher(() => options); + var request = new HttpRequestMessage(HttpMethod.Get, "/test"); + + enricher.Enrich(request); + + Assert.True(request.Options.TryGetValue(OptionsKey.CancellationTokenKey, out var ct)); + Assert.True(ct.CanBeCanceled); + Assert.False(ct.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_not_set_cancellation_token_when_timeout_is_infinite() + { + var options = CreateOptions(System.Threading.Timeout.InfiniteTimeSpan); + var enricher = new RequestEnricher(() => options); + var request = new HttpRequestMessage(HttpMethod.Get, "/test"); + + enricher.Enrich(request); + + Assert.False(request.Options.TryGetValue(OptionsKey.CancellationTokenKey, out _)); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_not_overwrite_existing_cancellation_token() + { + var options = CreateOptions(TimeSpan.FromSeconds(5)); + var enricher = new RequestEnricher(() => options); + var request = new HttpRequestMessage(HttpMethod.Get, "/test"); + + using var userCts = new CancellationTokenSource(); + request.SetCancellationToken(userCts.Token); + + enricher.Enrich(request); + + Assert.True(request.Options.TryGetValue(OptionsKey.CancellationTokenKey, out var ct)); + Assert.Equal(userCts.Token, ct); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_use_per_request_timeout_over_default() + { + var options = CreateOptions(TimeSpan.FromSeconds(30)); + var enricher = new RequestEnricher(() => options); + var request = new HttpRequestMessage(HttpMethod.Get, "/test") + .WithTimeout(TimeSpan.FromMilliseconds(100)); + + enricher.Enrich(request); + + Assert.True(request.Options.TryGetValue(OptionsKey.CancellationTokenKey, out var ct)); + Assert.True(ct.CanBeCanceled); + } + + [Fact(Timeout = 5000)] + public async Task Enrich_timeout_should_fire_cancellation_token() + { + var options = CreateOptions(TimeSpan.FromMilliseconds(50)); + var enricher = new RequestEnricher(() => options); + var request = new HttpRequestMessage(HttpMethod.Get, "/test"); + + enricher.Enrich(request); + + Assert.True(request.Options.TryGetValue(OptionsKey.CancellationTokenKey, out var ct)); + await Task.Delay(200, TestContext.Current.CancellationToken); + Assert.True(ct.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public async Task Enrich_should_cancel_pending_request_on_timeout() + { + var options = CreateOptions(TimeSpan.FromMilliseconds(50)); + var enricher = new RequestEnricher(() => options); + var request = new HttpRequestMessage(HttpMethod.Get, "/test"); + + var pending = PendingRequest.Rent(); + request.Options.Set(OptionsKey.Key, pending); + request.Options.Set(OptionsKey.VersionKey, pending.Version); + + enricher.Enrich(request); + + await Assert.ThrowsAnyAsync(async () => + { + await pending.GetValueTask(); + }); + } +} diff --git a/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ConnectionActorSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ConnectionActorSpec.cs index 9fff44dbb..39c6ab519 100644 --- a/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ConnectionActorSpec.cs +++ b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ConnectionActorSpec.cs @@ -1,106 +1,80 @@ +using Akka; using Akka.Actor; +using Akka.Streams; +using Akka.Streams.Dsl; using Akka.TestKit.Xunit; +using Microsoft.AspNetCore.Http.Features; +using Servus.Akka.Transport; +using System.Net; +using TurboHTTP.Server; +using TurboHTTP.Streams; using TurboHTTP.Streams.Lifecycle; namespace TurboHTTP.Tests.Streams.Stages.Lifecycle; public sealed class ConnectionActorSpec : TestKit { - private sealed class ParentActor : ReceiveActor + private sealed class PassthroughEngine : IServerProtocolEngine { - public sealed record CreateConnection(string ConnectionId); + public Version ProtocolVersion => new(1, 1); - private IActorRef? _testActor; - - public ParentActor() + public BidiFlow + CreateFlow(IServiceProvider? services = null) { - Receive(msg => - { - _testActor = Sender; - var connectionActor = Context.ActorOf( - ConnectionActor.Create(msg.ConnectionId), - "connection"); - _testActor.Tell(connectionActor, ActorRefs.NoSender); - }); - - Receive(msg => - { - _testActor?.Tell(msg, ActorRefs.NoSender); - }); + var top = Flow.Create() + .Select(_ => (IFeatureCollection)new FeatureCollection()); + var bottom = Flow.Create() + .Select(_ => new DisconnectTransport(DisconnectReason.Graceful) as ITransportOutbound); + return BidiFlow.FromFlows(top, bottom); } } - [Fact(Timeout = 5000)] - public void ConnectionActor_should_report_completion_on_stream_success() - { - var parentActor = Sys.ActorOf(Props.Create(() => new ParentActor()), "parent"); - - parentActor.Tell(new ParentActor.CreateConnection("conn-1"), TestActor); - var connectionActor = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); + private static IGraph, NotUsed> PassthroughBridgeGraph() + => Flow.Create(); - connectionActor.Tell(new ConnectionActor.StreamCompleted(null)); - - var completed = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("conn-1", completed.ConnectionId); - Assert.Equal(ConnectionCompletionReason.Normal, completed.Reason); - } - - [Fact(Timeout = 5000)] - public void ConnectionActor_should_report_error_on_stream_failure() + private static Flow FakeConnectionFlow() { - var parentActor = Sys.ActorOf(Props.Create(() => new ParentActor()), "parent2"); - - parentActor.Tell(new ParentActor.CreateConnection("conn-2"), TestActor); - var connectionActor = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); - - connectionActor.Tell(new ConnectionActor.StreamCompleted(new InvalidOperationException("boom"))); - - var completed = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("conn-2", completed.ConnectionId); - Assert.Equal(ConnectionCompletionReason.Error, completed.Reason); + var connInfo = new ConnectionInfo( + new IPEndPoint(IPAddress.Loopback, 8080), + new IPEndPoint(IPAddress.Loopback, 8081), + TransportProtocol.Tcp); + + return Flow.FromSinkAndSource( + Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), + Source.Single(new TransportConnected(connInfo))); } - [Fact(Timeout = 5000)] - public void ConnectionActor_should_stop_self_after_stream_completes() + private static Flow HangingConnectionFlow() { - var parentActor = Sys.ActorOf(Props.Create(() => new ParentActor()), "parent3"); - - parentActor.Tell(new ParentActor.CreateConnection("conn-3"), TestActor); - var connectionActor = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); - - connectionActor.Tell(new ConnectionActor.StreamCompleted(null)); - - ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); + return Flow.FromSinkAndSource( + Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), + Source.Maybe().MapMaterializedValue(_ => NotUsed.Instance)); } - [Fact(Timeout = 5000)] - public void ConnectionActor_should_report_timeout_on_graceful_stop_without_stream() + [Fact(Timeout = 10000)] + public void ConnectionActor_should_materialize_and_complete_on_connection_close() { - var parentActor = Sys.ActorOf(Props.Create(() => new ParentActor()), "parent4"); - - parentActor.Tell(new ParentActor.CreateConnection("conn-4"), TestActor); - var connectionActor = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); + var engine = new PassthroughEngine(); + var options = new TurboServerOptions(); - connectionActor.Tell(new ConnectionActor.GracefulStop(TimeSpan.FromMilliseconds(200))); + var actor = Sys.ActorOf(ConnectionActor.Props( + 1, FakeConnectionFlow(), PassthroughBridgeGraph(), engine, options)); - var completed = ExpectMsg(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("conn-4", completed.ConnectionId); - Assert.Equal(ConnectionCompletionReason.Timeout, completed.Reason); + Watch(actor); + ExpectTerminated(actor, TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); } - [Fact(Timeout = 5000)] - public void ConnectionActor_should_report_timeout_when_drain_exceeds_limit() + [Fact(Timeout = 10000)] + public void ConnectionActor_should_drain_on_drain_message() { - var parentActor = Sys.ActorOf(Props.Create(() => new ParentActor()), "parent5"); - - parentActor.Tell(new ParentActor.CreateConnection("conn-5"), TestActor); - var connectionActor = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); + var engine = new PassthroughEngine(); + var options = new TurboServerOptions(); - connectionActor.Tell(new ConnectionActor.GracefulStop(TimeSpan.FromMilliseconds(200))); + var actor = Sys.ActorOf(ConnectionActor.Props( + 1, HangingConnectionFlow(), PassthroughBridgeGraph(), engine, options)); - var completed = ExpectMsg(TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("conn-5", completed.ConnectionId); - Assert.Equal(ConnectionCompletionReason.Timeout, completed.Reason); + Watch(actor); + actor.Tell(new ConnectionActor.Drain()); + ExpectTerminated(actor, TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); } } diff --git a/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ListenerActorSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ListenerActorSpec.cs new file mode 100644 index 000000000..feedec59a --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ListenerActorSpec.cs @@ -0,0 +1,76 @@ +using Akka; +using Akka.Streams; +using Akka.Streams.Dsl; +using Akka.TestKit.Xunit; +using Microsoft.AspNetCore.Http.Features; +using Servus.Akka.Transport; +using TurboHTTP.Server; +using TurboHTTP.Streams; +using TurboHTTP.Streams.Lifecycle; + +namespace TurboHTTP.Tests.Streams.Stages.Lifecycle; + +public sealed class ListenerActorSpec : TestKit +{ + private sealed class DummyListenerFactory : IListenerFactory + { + private readonly int _boundPort; + + public DummyListenerFactory(int boundPort = 8080) + { + _boundPort = boundPort; + } + + public Source, Task> Bind(ListenerOptions options) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + tcs.SetResult(_boundPort); + return Source.Empty>() + .MapMaterializedValue(_ => tcs.Task); + } + } + + private static IGraph, NotUsed> PassthroughBridgeGraph() + => Flow.Create(); + + private sealed class DummyProtocolEngine : IServerProtocolEngine + { + public Version ProtocolVersion => new(1, 1); + + public BidiFlow + CreateFlow(IServiceProvider? services = null) + { + return BidiFlow.FromFlows( + Flow.Create() + .Select(_ => new FeatureCollection() as IFeatureCollection), + Flow.Create() + .Select(_ => + { + var buffer = TransportBuffer.Rent(1); + buffer.Dispose(); + return TransportData.Rent(buffer) as ITransportOutbound; + })); + } + } + + [Fact(Timeout = 5000)] + public void Listener_should_bind_and_report_listening_started() + { + var listener = Sys.ActorOf(ListenerActor.Create( + new DummyListenerFactory(9000), + new TcpListenerOptions { Host = "localhost", Port = 0 }, + new TurboServerOptions(), + PassthroughBridgeGraph(), + new DummyProtocolEngine())); + + listener.Tell(new ListenerActor.StartListening(), TestActor); + + var listening = ExpectMsg( + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(9000, listening.BoundPort); + Assert.NotNull(listening.Handle); + Assert.NotNull(listening.Handle.AcceptSwitch); + Assert.NotNull(listening.Handle.CompletionTask); + } +} diff --git a/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ServerSupervisorActorSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ServerSupervisorActorSpec.cs index a7f1c601a..9c9626b9d 100644 --- a/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ServerSupervisorActorSpec.cs +++ b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ServerSupervisorActorSpec.cs @@ -1,33 +1,81 @@ +using Akka; using Akka.Actor; +using Akka.Streams; +using Akka.Streams.Dsl; using Akka.TestKit.Xunit; +using Microsoft.AspNetCore.Http.Features; +using Servus.Akka.Transport; +using TurboHTTP.Server; using TurboHTTP.Streams.Lifecycle; namespace TurboHTTP.Tests.Streams.Stages.Lifecycle; public sealed class ServerSupervisorActorSpec : TestKit { + private static IGraph, NotUsed> PassthroughBridge() + { + return Flow.Create(); + } + [Fact(Timeout = 5000)] - public void Supervisor_should_track_connection_started() + public void Supervisor_should_start_server_and_report_ready() { var supervisor = Sys.ActorOf(Props.Create(() => new ServerSupervisorActor())); - supervisor.Tell(new ListenerActor.ConnectionStarted("conn-1", TestActor)); - supervisor.Tell(new ServerSupervisorActor.GetConnectionCount()); + var bindings = new List + { + new() + { + Factory = new DummyListenerFactory(), + Options = new TcpListenerOptions { Host = "localhost", Port = 8080 } + } + }; - var count = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(1, count); + supervisor.Tell( + new ServerSupervisorActor.StartServer(PassthroughBridge(), new TurboServerOptions(), bindings), + TestActor); + + var ready = ExpectMsg( + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Single(ready.BoundPorts); } [Fact(Timeout = 5000)] - public void Supervisor_should_decrement_on_connection_completed() + public void Supervisor_should_drain_after_start() { var supervisor = Sys.ActorOf(Props.Create(() => new ServerSupervisorActor())); - supervisor.Tell(new ListenerActor.ConnectionStarted("conn-1", TestActor)); - supervisor.Tell(new ConnectionActor.ConnectionCompleted("conn-1", ConnectionCompletionReason.Normal)); - supervisor.Tell(new ServerSupervisorActor.GetConnectionCount()); + var bindings = new List + { + new() + { + Factory = new DummyListenerFactory(), + Options = new TcpListenerOptions { Host = "localhost", Port = 8080 } + } + }; + + supervisor.Tell( + new ServerSupervisorActor.StartServer(PassthroughBridge(), new TurboServerOptions(), bindings), + TestActor); - var count = ExpectMsg(cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(0, count); + ExpectMsg( + cancellationToken: TestContext.Current.CancellationToken); + + supervisor.Tell(new ServerSupervisorActor.BeginDrain(TimeSpan.FromSeconds(5)), TestActor); + + ExpectMsg( + cancellationToken: TestContext.Current.CancellationToken); + } + + private sealed class DummyListenerFactory : IListenerFactory + { + public Source, Task> Bind(ListenerOptions options) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + tcs.SetResult(options.Port); + return Source.Empty>() + .MapMaterializedValue(_ => tcs.Task); + } } } diff --git a/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageCallbackSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageCallbackSpec.cs new file mode 100644 index 000000000..0b6ca6093 --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageCallbackSpec.cs @@ -0,0 +1,200 @@ +using Akka.Streams.Dsl; +using Akka.Streams.TestKit; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Server; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Streams.Stages.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Streams.Stages.Server; + +public sealed class ApplicationBridgeStageCallbackSpec : StreamTestBase +{ + private sealed class CallbackTrackingApplication(Func handler) + : IHttpApplication + { + public IFeatureCollection CreateContext(IFeatureCollection contextFeatures) => contextFeatures; + public Task ProcessRequestAsync(IFeatureCollection context) => handler(context); + public void DisposeContext(IFeatureCollection context, Exception? exception) { } + } + + private static IFeatureCollection RequestWithCallbacks() + { + var requestFeature = new TurboHttpRequestFeature { Protocol = "HTTP/2" }; + return FeatureCollectionFactory.Create(requestFeature, hasBody: false); + } + + private static ApplicationBridgeStage CreateStage( + IHttpApplication app) + { + var options = new TurboServerOptions + { + HandlerTimeout = TimeSpan.FromSeconds(30), + HandlerGracePeriod = TimeSpan.FromSeconds(5), + }; + return new ApplicationBridgeStage( + app, + 10, + options.HandlerTimeout, + options.HandlerGracePeriod); + } + + [Fact(Timeout = 5000)] + public void OnStarting_should_fire_when_handler_writes_response_body() + { + var onStartingCalled = false; + + var app = new CallbackTrackingApplication(features => + { + var responseFeature = features.Get()!; + responseFeature.OnStarting(_ => + { + onStartingCalled = true; + return Task.CompletedTask; + }, null!); + + var bodyFeature = features.Get()!; + return bodyFeature.Writer.WriteAsync(new ReadOnlyMemory("hello"u8.ToArray())).AsTask(); + }); + + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(RequestWithCallbacks(), TestContext.Current.CancellationToken); + downstream.ExpectNext(TestContext.Current.CancellationToken); + + Assert.True(onStartingCalled); + } + + [Fact(Timeout = 5000)] + public void OnStarting_should_fire_when_handler_calls_StartAsync() + { + var onStartingCalled = false; + + var app = new CallbackTrackingApplication(async features => + { + var responseFeature = features.Get()!; + responseFeature.OnStarting(_ => + { + onStartingCalled = true; + return Task.CompletedTask; + }, null!); + + var bodyFeature = features.Get()!; + await bodyFeature.StartAsync(); + }); + + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(RequestWithCallbacks(), TestContext.Current.CancellationToken); + downstream.ExpectNext(TestContext.Current.CancellationToken); + + Assert.True(onStartingCalled); + } + + [Fact(Timeout = 5000)] + public void OnStarting_should_allow_modifying_headers_before_flush() + { + var app = new CallbackTrackingApplication(features => + { + var responseFeature = features.Get()!; + responseFeature.OnStarting(_ => + { + responseFeature.Headers["X-Added-By-Callback"] = "true"; + return Task.CompletedTask; + }, null!); + + var bodyFeature = features.Get()!; + return bodyFeature.Writer.WriteAsync(new ReadOnlyMemory("data"u8.ToArray())).AsTask(); + }); + + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(RequestWithCallbacks(), TestContext.Current.CancellationToken); + var result = downstream.ExpectNext(TestContext.Current.CancellationToken); + + var headers = result.Get()!.Headers; + Assert.Equal("true", headers["X-Added-By-Callback"].ToString()); + } + + [Fact(Timeout = 5000)] + public void OnCompleted_should_fire_when_handler_completes_successfully() + { + var onCompletedCalled = false; + + var app = new CallbackTrackingApplication(features => + { + var responseFeature = features.Get()!; + responseFeature.OnCompleted(_ => + { + onCompletedCalled = true; + return Task.CompletedTask; + }, null!); + + return Task.CompletedTask; + }); + + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(RequestWithCallbacks(), TestContext.Current.CancellationToken); + downstream.ExpectNext(TestContext.Current.CancellationToken); + + Assert.True(onCompletedCalled); + } + + [Fact(Timeout = 5000)] + public void OnCompleted_should_fire_when_handler_faults() + { + var onCompletedCalled = false; + + var app = new CallbackTrackingApplication(features => + { + var responseFeature = features.Get()!; + responseFeature.OnCompleted(_ => + { + onCompletedCalled = true; + return Task.CompletedTask; + }, null!); + + throw new InvalidOperationException("handler error"); + }); + + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(RequestWithCallbacks(), TestContext.Current.CancellationToken); + var result = downstream.ExpectNext(TestContext.Current.CancellationToken); + + Assert.Equal(500, result.Get()!.StatusCode); + Assert.True(onCompletedCalled); + } +} diff --git a/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStagePostStopSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStagePostStopSpec.cs new file mode 100644 index 000000000..e28bfdc92 --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStagePostStopSpec.cs @@ -0,0 +1,135 @@ +using Akka.Streams.Dsl; +using Akka.Streams.TestKit; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Server; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Streams.Stages.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Streams.Stages.Server; + +public sealed class ApplicationBridgeStagePostStopSpec : StreamTestBase +{ + private sealed class TrackingApplication : IHttpApplication + { + public readonly List<(IFeatureCollection Context, Exception? Error)> DisposedContexts = []; + + public IFeatureCollection CreateContext(IFeatureCollection contextFeatures) => contextFeatures; + + public Task ProcessRequestAsync(IFeatureCollection context) + { + var tcs = context.Get()?.Tcs; + return tcs?.Task ?? Task.CompletedTask; + } + + public void DisposeContext(IFeatureCollection context, Exception? exception) + { + DisposedContexts.Add((context, exception)); + } + } + + private sealed class TestCompletionFeature + { + public TaskCompletionSource Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private static IFeatureCollection RequestWithLifetime() + { + var fc = new FeatureCollection(); + fc.Set(new TurboHttpRequestFeature { Protocol = "HTTP/2" }); + fc.Set(new TurboHttpResponseFeature()); + fc.Set(new TurboHttpResponseBodyFeature()); + fc.Set(new TurboHttpRequestLifetimeFeature()); + fc.Set(new TestCompletionFeature()); + return fc; + } + + private static (ApplicationBridgeStage Stage, TrackingApplication App) CreateStage() + { + var app = new TrackingApplication(); + var options = new TurboServerOptions + { + HandlerTimeout = TimeSpan.FromSeconds(30), + HandlerGracePeriod = TimeSpan.FromSeconds(5), + }; + var stage = new ApplicationBridgeStage( + app, + 10, + options.HandlerTimeout, + options.HandlerGracePeriod); + return (stage, app); + } + + [Fact(Timeout = 5000)] + public void PostStop_should_cancel_RequestAborted_for_inflight_requests() + { + var (stage, _) = CreateStage(); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(10); + + var request = RequestWithLifetime(); + var lifetime = request.Get()!; + var token = lifetime.RequestAborted; + Assert.False(token.IsCancellationRequested); + + upstream.SendNext(request, TestContext.Current.CancellationToken); + + upstream.SendError(new Exception("connection dropped"), TestContext.Current.CancellationToken); + downstream.ExpectError(TestContext.Current.CancellationToken); + + Assert.True(token.IsCancellationRequested); + } + + [Fact(Timeout = 5000)] + public void PostStop_should_dispose_app_contexts_for_inflight_requests() + { + var (stage, app) = CreateStage(); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(10); + + var req1 = RequestWithLifetime(); + var req2 = RequestWithLifetime(); + upstream.SendNext(req1, TestContext.Current.CancellationToken); + upstream.SendNext(req2, TestContext.Current.CancellationToken); + + Assert.Empty(app.DisposedContexts); + + upstream.SendError(new Exception("connection dropped"), TestContext.Current.CancellationToken); + downstream.ExpectError(TestContext.Current.CancellationToken); + + Assert.Equal(2, app.DisposedContexts.Count); + } + + [Fact(Timeout = 5000)] + public void PostStop_should_cancel_handler_timeout_CTS_for_inflight_requests() + { + var (stage, _) = CreateStage(); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(10); + + var request = RequestWithLifetime(); + upstream.SendNext(request, TestContext.Current.CancellationToken); + + upstream.SendError(new Exception("connection dropped"), TestContext.Current.CancellationToken); + downstream.ExpectError(TestContext.Current.CancellationToken); + + var lifetime = request.Get()!; + Assert.True(lifetime.RequestAborted.IsCancellationRequested); + } +} diff --git a/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageSpec.cs new file mode 100644 index 000000000..611ad211c --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageSpec.cs @@ -0,0 +1,126 @@ +using Akka.Streams.Dsl; +using Akka.Streams.TestKit; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Server; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Streams.Stages.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Streams.Stages.Server; + +public sealed class ApplicationBridgeStageSpec : StreamTestBase +{ + private sealed class FakeApplication(Func handler) + : IHttpApplication + { + public IFeatureCollection CreateContext(IFeatureCollection contextFeatures) => contextFeatures; + public Task ProcessRequestAsync(IFeatureCollection context) => handler(context); + + public void DisposeContext(IFeatureCollection context, Exception? exception) + { + } + } + + private static IFeatureCollection Request(string protocol = "HTTP/2") + { + var fc = new FeatureCollection(); + fc.Set(new TurboHttpRequestFeature { Protocol = protocol }); + fc.Set(new TurboHttpResponseFeature()); + fc.Set(new TurboHttpResponseBodyFeature()); + return fc; + } + + private static ApplicationBridgeStage CreateStage( + IHttpApplication app, + TurboServerOptions? options = null) + { + options ??= new TurboServerOptions(); + return new ApplicationBridgeStage( + app, 10, options.HandlerTimeout, options.HandlerGracePeriod); + } + + [Fact(Timeout = 5000)] + public void ApplicationBridgeStage_should_dispatch_immediate_completions() + { + var app = new FakeApplication(_ => Task.CompletedTask); + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(Request(), TestContext.Current.CancellationToken); + var emitted = downstream.ExpectNext(TestContext.Current.CancellationToken); + Assert.NotNull(emitted); + } + + [Fact(Timeout = 5000)] + public void ApplicationBridgeStage_should_emit_all_when_handlers_complete_out_of_order() + { + var tcs1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tcs2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tcs3 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var handlerQueue = new Queue(new[] { tcs1.Task, tcs2.Task, tcs3.Task }); + var app = new FakeApplication(_ => handlerQueue.Dequeue()); + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(3); + upstream.SendNext(Request(), TestContext.Current.CancellationToken); + upstream.SendNext(Request(), TestContext.Current.CancellationToken); + upstream.SendNext(Request(), TestContext.Current.CancellationToken); + + tcs2.SetResult(); + tcs1.SetResult(); + tcs3.SetResult(); + + downstream.ExpectNext(TestContext.Current.CancellationToken); + downstream.ExpectNext(TestContext.Current.CancellationToken); + downstream.ExpectNext(TestContext.Current.CancellationToken); + } + + [Fact(Timeout = 5000)] + public void ApplicationBridgeStage_should_handle_handler_exceptions() + { + var app = new FakeApplication(_ => throw new InvalidOperationException("Test error")); + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(Request(), TestContext.Current.CancellationToken); + + var result = downstream.ExpectNext(TestContext.Current.CancellationToken); + Assert.Equal(500, result.Get()?.StatusCode); + } + + [Fact(Timeout = 5000)] + public void ApplicationBridgeStage_should_complete_upstream_finished_no_pending() + { + var app = new FakeApplication(_ => Task.CompletedTask); + var stage = CreateStage(app); + + var (upstream, downstream) = this.SourceProbe() + .Via(stage) + .ToMaterialized(this.SinkProbe(), Keep.Both) + .Run(Materializer); + + downstream.Request(1); + upstream.SendNext(Request(), TestContext.Current.CancellationToken); + downstream.ExpectNext(TestContext.Current.CancellationToken); + + upstream.SendComplete(TestContext.Current.CancellationToken); + downstream.ExpectComplete(TestContext.Current.CancellationToken); + } +} diff --git a/src/TurboHTTP.Tests/TestSupport/ClientOptionDefaults.cs b/src/TurboHTTP.Tests/TestSupport/ClientOptionDefaults.cs new file mode 100644 index 000000000..235c48caf --- /dev/null +++ b/src/TurboHTTP.Tests/TestSupport/ClientOptionDefaults.cs @@ -0,0 +1,76 @@ +using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Protocol.Syntax.Http2.Options; +using TurboHTTP.Protocol.Syntax.Http3.Options; + +namespace TurboHTTP.Tests.TestSupport; + +/// +/// Test-only factory for the internal per-protocol client decoder/encoder option records. +/// These records used to expose a static Default property; that was removed when the +/// refactor made every member required (production sources the values from +/// via the projection layer). The values here +/// reproduce the previous static defaults verbatim so existing specs keep their exact behaviour. +/// +internal static class ClientOptionDefaults +{ + public static Http10ClientDecoderOptions Http10Decoder() => new() + { + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 4 * 1024 * 1024, + MaxStreamedBodySize = null, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + HeaderLineMaxLength = 8 * 1024, + AllowObsFold = false, + }; + + public static Http11ClientDecoderOptions Http11Decoder() => new() + { + StreamingThreshold = 64 * 1024, + MaxBufferedBodySize = 4 * 1024 * 1024, + MaxStreamedBodySize = null, + MaxHeaderBytes = 32 * 1024, + MaxHeaderCount = 100, + HeaderLineMaxLength = 8 * 1024, + MaxChunkExtensionLength = int.MaxValue, + AllowObsFold = false, + }; + + public static Http11ClientEncoderOptions Http11Encoder() => new() + { + AutoHost = true, + AutoAcceptEncoding = true, + ChunkSize = 16 * 1024, + }; + + public static Http2ClientDecoderOptions Http2Decoder() => new() + { + MaxConcurrentStreams = 100, + InitialConnectionWindowSize = 64 * 1024 * 1024, + InitialStreamWindowSize = 1 * 1024 * 1024, + MaxStreamWindowSize = 16 * 1024 * 1024, + WindowScaleThresholdMultiplier = 1.0, + EnableAdaptiveWindowScaling = true, + MaxHeaderSize = 16 * 1024, + MaxHeaderListSize = 64 * 1024, + }; + + public static Http2ClientEncoderOptions Http2Encoder() => new() + { + HeaderTableSize = 64 * 1024, + MaxFrameSize = 16 * 1024, + }; + + public static Http3ClientDecoderOptions Http3Decoder() => new() + { + MaxConcurrentStreams = 100, + MaxFieldSectionSize = 64 * 1024, + }; + + public static Http3ClientEncoderOptions Http3Encoder() => new() + { + QpackMaxTableCapacity = 16 * 1024, + QpackBlockedStreams = 100, + }; +} diff --git a/src/TurboHTTP.Tests/TurboHTTP.Tests.csproj b/src/TurboHTTP.Tests/TurboHTTP.Tests.csproj index 63ee2601e..767c368e9 100644 --- a/src/TurboHTTP.Tests/TurboHTTP.Tests.csproj +++ b/src/TurboHTTP.Tests/TurboHTTP.Tests.csproj @@ -6,12 +6,13 @@ + - + diff --git a/src/TurboHTTP.slnx b/src/TurboHTTP.slnx index c517ce3fe..1577d376b 100644 --- a/src/TurboHTTP.slnx +++ b/src/TurboHTTP.slnx @@ -24,10 +24,9 @@ - - - - + + + diff --git a/src/TurboHTTP/Client/CacheOptions.cs b/src/TurboHTTP/Client/CacheOptions.cs index 86fb4d5b2..9cc5123ed 100644 --- a/src/TurboHTTP/Client/CacheOptions.cs +++ b/src/TurboHTTP/Client/CacheOptions.cs @@ -2,6 +2,10 @@ namespace TurboHTTP.Client; +/// +/// Configuration for the HTTP response cache applied by the client pipeline. +/// Pass to WithCache on an . +/// public sealed class CacheOptions { /// Maximum number of entries held in the LRU store. Default 1 000. @@ -11,7 +15,13 @@ public sealed class CacheOptions /// Maximum body size (in bytes) for a single stored response. Default 50 MiB. /// Responses larger than this limit are not cached. /// - public long MaxBodyBytes { get; set; } = 52_428_800; // 50 MiB + public long MaxBodySize { get; set; } = 50 * 1024 * 1024; + + /// + /// Maximum total size (in bytes) of all cached response bodies combined. + /// When exceeded, the least-recently-used entries are evicted. Default 256 MiB. + /// + public long MaxTotalSize { get; set; } = 256 * 1024 * 1024; /// /// When true the cache acts as a shared (proxy) cache: s-maxage is honoured, @@ -24,7 +34,8 @@ public sealed class CacheOptions internal CachePolicy To() => new() { MaxEntries = MaxEntries, - MaxBodyBytes = MaxBodyBytes, - SharedCache = SharedCache, + MaxBodyBytes = MaxBodySize, + MaxTotalBytes = MaxTotalSize, + SharedCache = SharedCache }; } \ No newline at end of file diff --git a/src/TurboHTTP/Client/ClientOptionsProjections.cs b/src/TurboHTTP/Client/ClientOptionsProjections.cs new file mode 100644 index 000000000..979ff7e60 --- /dev/null +++ b/src/TurboHTTP/Client/ClientOptionsProjections.cs @@ -0,0 +1,74 @@ +using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Protocol.Syntax.Http2.Options; +using TurboHTTP.Protocol.Syntax.Http3.Options; + +namespace TurboHTTP.Client; + +/// +/// Projects the public onto the per-protocol decoder/encoder +/// option records, mirroring the server-side ServerOptionsProjections. State machines call these +/// instead of constructing the option records inline. +/// +internal static class ClientOptionsProjections +{ + public static Http10ClientDecoderOptions ToHttp10DecoderOptions(this TurboClientOptions o) => new() + { + StreamingThreshold = o.Http1.MaxBufferedResponseBodySize, + MaxBufferedBodySize = o.Http1.MaxBufferedResponseBodySize, + MaxStreamedBodySize = o.MaxStreamedResponseBodySize, + MaxHeaderBytes = o.Http1.MaxResponseHeadersLength * 1024, + MaxHeaderCount = o.Http1.MaxResponseHeaderCount, + HeaderLineMaxLength = o.Http1.MaxResponseHeaderLineLength, + AllowObsFold = false + }; + + public static Http11ClientDecoderOptions ToHttp11DecoderOptions(this TurboClientOptions o) => new() + { + StreamingThreshold = o.Http1.MaxBufferedResponseBodySize, + MaxBufferedBodySize = o.Http1.MaxBufferedResponseBodySize, + MaxStreamedBodySize = o.MaxStreamedResponseBodySize, + MaxHeaderBytes = o.Http1.MaxResponseHeadersLength * 1024, + MaxHeaderCount = o.Http1.MaxResponseHeaderCount, + HeaderLineMaxLength = o.Http1.MaxResponseHeaderLineLength, + MaxChunkExtensionLength = o.Http1.MaxChunkExtensionLength, + AllowObsFold = false + }; + + public static Http11ClientEncoderOptions ToHttp11EncoderOptions(this TurboClientOptions o) => new() + { + AutoHost = o.Http1.AutoHost, + AutoAcceptEncoding = o.Http1.AutoAcceptEncoding, + ChunkSize = o.RequestBodyChunkSize + }; + + public static Http2ClientDecoderOptions ToHttp2DecoderOptions(this TurboClientOptions o) => new() + { + MaxConcurrentStreams = o.Http2.MaxConcurrentStreams, + InitialConnectionWindowSize = o.Http2.InitialConnectionWindowSize, + InitialStreamWindowSize = o.Http2.InitialStreamWindowSize, + MaxStreamWindowSize = o.Http2.MaxStreamWindowSize, + WindowScaleThresholdMultiplier = o.Http2.WindowScaleThresholdMultiplier, + EnableAdaptiveWindowScaling = o.Http2.EnableAdaptiveWindowScaling, + MaxHeaderSize = 16 * 1024, + MaxHeaderListSize = o.Http2.MaxResponseHeaderListSize + }; + + public static Http2ClientEncoderOptions ToHttp2EncoderOptions(this TurboClientOptions o) => new() + { + HeaderTableSize = o.Http2.HeaderTableSize, + MaxFrameSize = o.Http2.MaxFrameSize + }; + + public static Http3ClientDecoderOptions ToHttp3DecoderOptions(this TurboClientOptions o) => new() + { + MaxConcurrentStreams = o.Http3.MaxConcurrentStreams, + MaxFieldSectionSize = o.Http3.MaxFieldSectionSize + }; + + public static Http3ClientEncoderOptions ToHttp3EncoderOptions(this TurboClientOptions o) => new() + { + QpackMaxTableCapacity = o.Http3.QpackMaxTableCapacity, + QpackBlockedStreams = o.Http3.QpackBlockedStreams + }; +} diff --git a/src/TurboHTTP/Client/CompressionOptions.cs b/src/TurboHTTP/Client/CompressionOptions.cs index 2b3a63c38..5afc2116a 100644 --- a/src/TurboHTTP/Client/CompressionOptions.cs +++ b/src/TurboHTTP/Client/CompressionOptions.cs @@ -1,25 +1,30 @@ +using TurboHTTP.Protocol; using TurboHTTP.Protocol.Semantics; namespace TurboHTTP.Client; +/// +/// Configuration for request body compression applied by the client before sending. +/// Pass to WithRequestCompression on an . +/// public sealed class CompressionOptions { /// /// The content encoding to apply (e.g. "gzip", "deflate", "br"). /// Default is "gzip". /// - public string Encoding { get; set; } = "gzip"; + public string Encoding { get; set; } = WellKnownHeaders.GzipValue; /// - /// Minimum request body size in bytes that triggers compression. + /// Minimum request body size (in bytes) that triggers compression. /// Bodies smaller than this threshold pass through uncompressed. /// Default is 1024. /// - public long MinBodySizeBytes { get; set; } = 1024; + public long MinBodySize { get; set; } = 1024; internal CompressionPolicy To() => new() { Encoding = Encoding, - MinBodySizeBytes = MinBodySizeBytes, + MinBodySizeBytes = MinBodySize }; } \ No newline at end of file diff --git a/src/TurboHTTP/Client/Expect100Options.cs b/src/TurboHTTP/Client/Expect100Options.cs index a4eaa22dd..4ce3c9313 100644 --- a/src/TurboHTTP/Client/Expect100Options.cs +++ b/src/TurboHTTP/Client/Expect100Options.cs @@ -2,17 +2,21 @@ namespace TurboHTTP.Client; +/// +/// Configuration for the Expect: 100-continue handshake applied to large request bodies. +/// Pass to WithExpectContinue on an . +/// public sealed class Expect100Options { /// - /// Minimum request body size in bytes that triggers the Expect: 100-continue header. + /// Minimum request body size (in bytes) that triggers the Expect: 100-continue header. /// Requests with a body smaller than this threshold pass through unchanged. /// Default is 1024. /// - public long MinBodySizeBytes { get; set; } = 1024; + public long MinBodySize { get; set; } = 1024; internal Expect100Policy To() => new() { - MinBodySizeBytes = MinBodySizeBytes, + MinBodySizeBytes = MinBodySize }; } \ No newline at end of file diff --git a/src/TurboHTTP/Client/Extensions.cs b/src/TurboHTTP/Client/Extensions.cs index 6bd62df9c..8e9e2687e 100644 --- a/src/TurboHTTP/Client/Extensions.cs +++ b/src/TurboHTTP/Client/Extensions.cs @@ -1,3 +1,4 @@ +using System.Threading; using Akka; using Akka.Streams.Dsl; using Servus.Akka.Streams.IO; @@ -6,8 +7,41 @@ namespace TurboHTTP.Client; +/// +/// Extension methods for and +/// that integrate with the TurboHTTP pipeline. +/// public static class Extensions { + /// + /// Sets a per-request timeout that overrides the client's global + /// for this request only. If no response arrives within , the request is + /// cancelled and SendAsync throws an . + /// + public static HttpRequestMessage WithTimeout(this HttpRequestMessage request, TimeSpan timeout) + { + request.Options.Set(OptionsKey.TimeoutKey, timeout); + return request; + } + + /// + /// Declares the first-party context (the site initiating this request) so the cookie jar can + /// enforce the SameSite attribute (RFC 6265bis §5.8.3). When set and the request target is + /// cross-site relative to , SameSite=Strict cookies are withheld, + /// and SameSite=Lax cookies are withheld on unsafe methods. When unset, requests are treated + /// as first-party. + /// + public static HttpRequestMessage WithFirstPartyContext(this HttpRequestMessage request, Uri firstParty) + { + request.Options.Set(OptionsKey.FirstPartyContextKey, firstParty); + return request; + } + + /// + /// Attaches a correlation ticket to and + /// returns a that completes when the pipeline delivers the matching response. + /// Intended for use with the channel-based API. + /// public static ValueTask GetResponseAsync(this HttpRequestMessage request, CancellationToken ct = default) { @@ -40,4 +74,16 @@ public static Source AsEventStream(this HttpResponseMe return StreamSource.From(response.Content.ReadAsStream()) .Via(SseParserFlow.Instance); } + + internal static void SetCancellationToken(this HttpRequestMessage request, CancellationToken ct) + { + request.Options.Set(OptionsKey.CancellationTokenKey, ct); + } + + internal static CancellationToken GetCancellationToken(this HttpRequestMessage request) + { + return request.Options.TryGetValue(OptionsKey.CancellationTokenKey, out var ct) + ? ct + : CancellationToken.None; + } } \ No newline at end of file diff --git a/src/TurboHTTP/Client/Http1Options.cs b/src/TurboHTTP/Client/Http1ClientOptions.cs similarity index 57% rename from src/TurboHTTP/Client/Http1Options.cs rename to src/TurboHTTP/Client/Http1ClientOptions.cs index ef5dda0b9..3d94b7637 100644 --- a/src/TurboHTTP/Client/Http1Options.cs +++ b/src/TurboHTTP/Client/Http1ClientOptions.cs @@ -1,11 +1,18 @@ namespace TurboHTTP.Client; /// -/// HTTP/1.x-specific configuration options. -/// Defaults are aligned with System.Net.Http.SocketsHttpHandler. +/// HTTP/1.x-specific client configuration. +/// Controls connection pooling, pipelining depth, header limits, and automatic header injection. +/// Defaults are aligned with System.Net.Http.SocketsHttpHandler where applicable. /// -public sealed class Http1Options +public sealed class Http1ClientOptions { + /// + /// Maximum response body size (in bytes) that is buffered fully in memory. + /// Bodies larger than this are exposed as a streaming pipe. Default is 64 KiB. + /// + public int MaxBufferedResponseBodySize { get; set; } = 64 * 1024; + /// /// Maximum number of concurrent TCP connections per server for HTTP/1.x. /// Each connection is managed as an independent substream. @@ -23,7 +30,7 @@ public sealed class Http1Options /// /// Maximum length of the response headers, in kilobytes (KB). /// This limits the combined size of all response header fields received from the server. - /// Default is 64 (same as SocketsHttpHandler.MaxResponseHeadersLength). + /// Default is 64. /// public int MaxResponseHeadersLength { get; set; } = 64; @@ -46,5 +53,23 @@ public sealed class Http1Options /// public int MaxReconnectAttempts { get; set; } = 3; + /// + /// Maximum number of header fields accepted in an HTTP/1.x response. + /// Guards against malicious servers flooding the client with header lines. Default is 100 + /// + public int MaxResponseHeaderCount { get; set; } = 100; + + /// + /// Maximum length (in bytes) of a single response status/header line in HTTP/1.x. + /// Default is 8 KB. + /// + public int MaxResponseHeaderLineLength { get; set; } = 8 * 1024; + + /// + /// Maximum length (in bytes) of the chunk extension on a single chunked-transfer chunk in an + /// HTTP/1.1 response. Default is (unbounded); set a smaller value to + /// guard against malicious servers. + /// + public int MaxChunkExtensionLength { get; set; } = int.MaxValue; } diff --git a/src/TurboHTTP/Client/Http2ClientOptions.cs b/src/TurboHTTP/Client/Http2ClientOptions.cs new file mode 100644 index 000000000..6b0187b01 --- /dev/null +++ b/src/TurboHTTP/Client/Http2ClientOptions.cs @@ -0,0 +1,132 @@ +namespace TurboHTTP.Client; + +/// +/// HTTP/2-specific client configuration. +/// Controls multiplexing, flow control windows, HPACK compression, frame sizes, and keep-alive pings. +/// Defaults are aligned with System.Net.Http.SocketsHttpHandler where applicable. +/// +public sealed class Http2ClientOptions +{ + /// + /// Maximum number of concurrent TCP connections per server for HTTP/2. + /// HTTP/2 multiplexes many streams over a single connection, so far fewer connections + /// are needed compared to HTTP/1.x. Default is 6 to spread load across multiple + /// actor turns at medium concurrency (CL=8–128). + /// + public int MaxConnectionsPerServer { get; set; } = 6; + + /// + /// Maximum number of concurrent HTTP/2 streams per connection. + /// Controls how many requests can be in-flight simultaneously on a single H/2 TCP connection, + /// enabling true request multiplexing within each substream. + /// Default is 100. + /// + public int MaxConcurrentStreams { get; set; } = 100; + + /// + /// Connection-level flow control window size in bytes (RFC 9113 §6.9). + /// Advertised via WINDOW_UPDATE on stream 0 during the connection preface. + /// Default is 16 MB. Higher values improve throughput on high-bandwidth links + /// but increase per-connection memory when consumers read slowly. + /// + public int InitialConnectionWindowSize { get; set; } = 64 * 1024 * 1024; + + /// + /// Per-stream initial flow control window size in bytes (RFC 9113 §6.9.2). + /// Advertised via SETTINGS_INITIAL_WINDOW_SIZE in the connection preface. + /// This is the starting window for adaptive scaling (when is true), + /// or the static window when scaling is disabled. Default is 1 MB. + /// When adaptive scaling is enabled, the window grows up to . + /// + public int InitialStreamWindowSize { get; set; } = 1 * 1024 * 1024; + + /// + /// Upper bound the per-stream receive window may grow to under adaptive scaling, in bytes. + /// Default is 16 MB. + /// + public int MaxStreamWindowSize { get; set; } = 16 * 1024 * 1024; + + /// + /// Threshold multiplier for adaptive window growth. Higher values grow the window less eagerly. + /// Default is 1.0. + /// + public double WindowScaleThresholdMultiplier { get; set; } = 1.0; + + /// + /// Enables client-side adaptive (BDP-based) receive-window scaling. When false, the per-stream + /// window stays static at . Default is true. + /// + public bool EnableAdaptiveWindowScaling { get; set; } = true; + + /// + /// Maximum HTTP/2 frame payload size in bytes the client is willing to RECEIVE (RFC 9113 §4.2). + /// Advertised to the server via SETTINGS_MAX_FRAME_SIZE in the connection preface. + /// This does NOT control the size of frames the client sends — outgoing frames are bounded by + /// the server's advertised limit (default 16,384 until the server's SETTINGS arrive). + /// Default is 64 KB. Valid range is [16,384, 16,777,215]; 16,384 is the RFC minimum/default. + /// SocketsHttpHandler does not expose this knob. + /// + public int MaxFrameSize { get; set; } = 64 * 1024; + + /// + /// HPACK dynamic table size in bytes (RFC 7541 §4.2). + /// Advertised via SETTINGS_HEADER_TABLE_SIZE in the connection preface. + /// Default is 64 KB. The RFC 7541 protocol default is 4,096; TurboHTTP uses a larger table + /// for more aggressive header compression. + /// + public int HeaderTableSize { get; set; } = 64 * 1024; + + /// + /// Maximum combined size (in bytes) of a decoded HPACK response header list the client will accept + /// (RFC 9113 §6.5.2, SETTINGS_MAX_HEADER_LIST_SIZE). Guards against header-bomb responses. + /// Default is 64 KB. + /// + public int MaxResponseHeaderListSize { get; set; } = 64 * 1024; + + /// + /// Maximum request body size (in bytes) that is serialized inline (single ArrayPool rent, + /// no background encoder). Bodies larger than this are streamed in chunks with backpressure. + /// Default is 64 KiB. + /// + public long MaxBufferedRequestBodySize { get; set; } = 64 * 1024; + + /// + /// Maximum bytes of outbound body data buffered per stream before the body encoder is paused. + /// Prevents unbounded memory growth during concurrent uploads. Default is 64 KiB. + /// + public long MaxRequestBodyBufferSize { get; set; } = 64 * 1024; + + /// + /// Maximum number of reconnect attempts when a TCP connection drops with in-flight requests. + /// After this many failed reconnects, the connection stage fails with an exception. + /// Default is 3. + /// + public int MaxReconnectAttempts { get; set; } = 3; + + /// + /// Maximum number of requests buffered during reconnection. When this limit is reached, + /// new requests fail instead of being buffered. Default is 64. + /// + public int MaxReconnectBufferSize { get; set; } = 64; + + /// + /// Delay before sending a keep-alive PING frame when no frames have been received. + /// Set to to disable keep-alive pings (default). + /// + public TimeSpan KeepAlivePingDelay { get; set; } = Timeout.InfiniteTimeSpan; + + /// + /// Timeout for keep-alive PING acknowledgment. If no frame is received within this + /// duration after a PING is sent, the connection is closed and reconnected. + /// Default is 20 seconds. + /// + public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20); + + /// + /// Controls when keep-alive PINGs are sent. + /// sends pings for the connection lifetime; + /// only while streams are active. + /// Default is . + /// + public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get; set; } = HttpKeepAlivePingPolicy.Always; +} \ No newline at end of file diff --git a/src/TurboHTTP/Client/Http2Options.cs b/src/TurboHTTP/Client/Http2Options.cs deleted file mode 100644 index 5666e64a7..000000000 --- a/src/TurboHTTP/Client/Http2Options.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace TurboHTTP.Client; - -/// -/// HTTP/2-specific configuration options. -/// Defaults are aligned with System.Net.Http.SocketsHttpHandler. -/// -public sealed class Http2Options -{ - /// - /// Maximum number of concurrent TCP connections per server for HTTP/2. - /// HTTP/2 multiplexes many streams over a single connection, so far fewer connections - /// are needed compared to HTTP/1.x. Default is 6 to spread load across multiple - /// actor turns at medium concurrency (CL=8–128). - /// - public int MaxConnectionsPerServer { get; set; } = 6; - - /// - /// Maximum number of concurrent HTTP/2 streams per connection. - /// Controls how many requests can be in-flight simultaneously on a single H/2 TCP connection, - /// enabling true request multiplexing within each substream. - /// Default is 100. - /// - public int MaxConcurrentStreams { get; set; } = 100; - - /// - /// Connection-level flow control window size in bytes (RFC 9113 §6.9). - /// Advertised via WINDOW_UPDATE on stream 0 during the connection preface. - /// Default is 64 MB. - /// - public int InitialConnectionWindowSize { get; set; } = 64 * 1024 * 1024; - - /// - /// Per-stream initial flow control window size in bytes (RFC 9113 §6.9.2). - /// Advertised via SETTINGS_INITIAL_WINDOW_SIZE in the connection preface. - /// Default is 65,535 (RFC 9113 §6.9.2 default). - /// - public int InitialStreamWindowSize { get; set; } = 2 * 1024 * 1024; - - /// - /// Maximum HTTP/2 frame payload size in bytes (RFC 9113 §4.2). - /// Advertised via SETTINGS_MAX_FRAME_SIZE in the connection preface. - /// Default is 16,384 (RFC 9113 minimum/default). - /// - public int MaxFrameSize { get; set; } = 64 * 1024; - - /// - /// HPACK dynamic table size in bytes (RFC 7541 §4.2). - /// Advertised via SETTINGS_HEADER_TABLE_SIZE in the connection preface. - /// Default is 4,096 (RFC 7541 default). - /// - public int HeaderTableSize { get; set; } = 64 * 1024; - - /// - /// Maximum number of reconnect attempts when a TCP connection drops with in-flight requests. - /// After this many failed reconnects, the connection stage fails with an exception. - /// Default is 3. - /// - public int MaxReconnectAttempts { get; set; } = 3; - - /// - /// Delay before sending a keep-alive PING frame when no frames have been received. - /// Set to to disable keep-alive pings (default). - /// - public TimeSpan KeepAlivePingDelay { get; set; } = Timeout.InfiniteTimeSpan; - - /// - /// Timeout for keep-alive PING acknowledgment. If no frame is received within this - /// duration after a PING is sent, the connection is closed and reconnected. - /// Default is 20 seconds. - /// - public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20); - - /// - /// Controls when keep-alive PINGs are sent. - /// sends pings for the connection lifetime; - /// only while streams are active. - /// Default is . - /// - public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get; set; } = HttpKeepAlivePingPolicy.Always; -} \ No newline at end of file diff --git a/src/TurboHTTP/Client/Http3Options.cs b/src/TurboHTTP/Client/Http3ClientOptions.cs similarity index 77% rename from src/TurboHTTP/Client/Http3Options.cs rename to src/TurboHTTP/Client/Http3ClientOptions.cs index 6195524a7..3b0e7db91 100644 --- a/src/TurboHTTP/Client/Http3Options.cs +++ b/src/TurboHTTP/Client/Http3ClientOptions.cs @@ -1,10 +1,11 @@ namespace TurboHTTP.Client; /// -/// HTTP/3-specific configuration options. -/// Defaults are aligned with System.Net.Http.SocketsHttpHandler. +/// HTTP/3-specific client configuration. +/// Controls QUIC connection pooling, stream concurrency, QPACK compression, and Alt-Svc discovery. +/// Defaults are aligned with System.Net.Http.SocketsHttpHandler where applicable. /// -public sealed class Http3Options +public sealed class Http3ClientOptions { /// /// Maximum number of concurrent QUIC connections per server for HTTP/3. @@ -22,9 +23,8 @@ public sealed class Http3Options /// /// Maximum capacity of the QPACK dynamic table in bytes. - /// Controls the size of the dynamic table used for header compression. /// Larger values improve compression ratio at the cost of memory. - /// Default is 4096 bytes. RFC 9204 §3.2.3. + /// Default is 16 KiB. RFC 9204 §3.2.3. /// public int QpackMaxTableCapacity { get; set; } = 16 * 1024; @@ -48,6 +48,19 @@ public sealed class Http3Options /// public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// + /// Maximum request body size (in bytes) that is serialized inline (single ArrayPool rent, + /// no background encoder). Bodies larger than this are streamed in chunks with backpressure. + /// Default is 64 KiB. + /// + public long MaxBufferedRequestBodySize { get; set; } = 64 * 1024; + + /// + /// Maximum bytes of outbound body data buffered per stream before the body encoder is paused. + /// Prevents unbounded memory growth during concurrent uploads. Default is 64 KiB. + /// + public long MaxRequestBodyBufferSize { get; set; } = 64 * 1024; + /// /// Maximum number of reconnect attempts when a QUIC connection drops with in-flight requests. /// After this many failed reconnects, the connection stage fails with an exception. @@ -55,15 +68,6 @@ public sealed class Http3Options /// public int MaxReconnectAttempts { get; set; } = 3; - /// - /// Whether to allow QUIC connection migration when the client's local IP address or port changes - /// (e.g., switching from Wi-Fi to cellular). When enabled, the QUIC connection continues - /// transparently after the address change. When disabled, the connection is closed and a new - /// connection is established via the reconnect mechanism. - /// Default is true. RFC 9000 §9. - /// - public bool AllowConnectionMigration { get; set; } = true; - /// /// Whether to automatically discover HTTP/3 availability via Alt-Svc headers (RFC 7838) /// in HTTP/1.1 and HTTP/2 responses. When enabled, Alt-Svc directives advertising "h3" diff --git a/src/TurboHTTP/Client/ITurboHttpClientBuilder.cs b/src/TurboHTTP/Client/ITurboHttpClientBuilder.cs index fb4a0365a..ff7c14d00 100644 --- a/src/TurboHTTP/Client/ITurboHttpClientBuilder.cs +++ b/src/TurboHTTP/Client/ITurboHttpClientBuilder.cs @@ -2,8 +2,15 @@ namespace TurboHTTP.Client; +/// +/// Configuration builder for a named TurboHttp client. +/// Returned by AddTurboHttpClient extension methods and passed to fluent configuration +/// extensions such as WithCookies, WithRetry, and AddHandler. +/// public interface ITurboHttpClientBuilder { + /// Gets the logical name of the client being configured. string Name { get; } + /// Gets the service collection the client registrations are added to. IServiceCollection Services { get; } } diff --git a/src/TurboHTTP/Client/ITurboHttpClientFactory.cs b/src/TurboHTTP/Client/ITurboHttpClientFactory.cs index 79a03054c..516425718 100644 --- a/src/TurboHTTP/Client/ITurboHttpClientFactory.cs +++ b/src/TurboHTTP/Client/ITurboHttpClientFactory.cs @@ -7,5 +7,6 @@ namespace TurboHTTP.Client; /// public interface ITurboHttpClientFactory { + /// Creates (or retrieves) an for the given . ITurboHttpClient CreateClient(string name); } diff --git a/src/TurboHTTP/Client/RedirectOptions.cs b/src/TurboHTTP/Client/RedirectOptions.cs index 7789588f7..055929be9 100644 --- a/src/TurboHTTP/Client/RedirectOptions.cs +++ b/src/TurboHTTP/Client/RedirectOptions.cs @@ -2,6 +2,10 @@ namespace TurboHTTP.Client; +/// +/// Configuration for the automatic redirect-following policy applied to HTTP responses +/// with 3xx status codes. Pass to WithRedirect on an . +/// public sealed class RedirectOptions { /// diff --git a/src/TurboHTTP/Client/RetryOptions.cs b/src/TurboHTTP/Client/RetryOptions.cs index f7045907d..77e3a3d18 100644 --- a/src/TurboHTTP/Client/RetryOptions.cs +++ b/src/TurboHTTP/Client/RetryOptions.cs @@ -2,6 +2,10 @@ namespace TurboHTTP.Client; +/// +/// Configuration for the automatic retry policy applied to failed or rate-limited requests. +/// Pass to WithRetry on an . +/// public sealed class RetryOptions { /// @@ -21,6 +25,6 @@ public sealed class RetryOptions internal RetryPolicy To() => new() { MaxRetries = MaxRetries, - RespectRetryAfter = RespectRetryAfter, + RespectRetryAfter = RespectRetryAfter }; } \ No newline at end of file diff --git a/src/TurboHTTP/Client/TurboClientOptions.cs b/src/TurboHTTP/Client/TurboClientOptions.cs index 994a0ed43..3baecf732 100644 --- a/src/TurboHTTP/Client/TurboClientOptions.cs +++ b/src/TurboHTTP/Client/TurboClientOptions.cs @@ -7,9 +7,18 @@ namespace TurboHTTP.Client; /// -/// Snapshot of configuration captured at request-submission time. -/// Passed into the pipeline so that per-request options reflect the values set on the client at the moment of submission. +/// Immutable snapshot of configuration captured at request-submission time. +/// Passed into the pipeline so per-request options always reflect the client state at the moment of submission. /// +/// The base URI used to resolve relative request URIs. +/// Default headers that are added to every outgoing request. +/// The default HTTP version for new requests. +/// The policy that determines which HTTP version is negotiated. +/// The per-request timeout applied by . +/// Optional credentials for server authentication. +/// When , the Authorization header is sent proactively without waiting for a 401. +/// Whether requests are routed through when one is configured. +/// The forward proxy requests are routed through. HTTP/3 requests are downgraded to HTTP/2 when the proxy applies, since QUIC cannot traverse an HTTP proxy. public record TurboRequestOptions( Uri? BaseAddress, HttpRequestHeaders DefaultRequestHeaders, @@ -17,12 +26,15 @@ public record TurboRequestOptions( HttpVersionPolicy DefaultVersionPolicy, TimeSpan Timeout, ICredentials? Credentials = null, - bool PreAuthenticate = false); + bool PreAuthenticate = false, + bool UseProxy = true, + IWebProxy? Proxy = null); /// -/// Configuration for a instance. -/// Property names and defaults are aligned with -/// where applicable, so TurboHttp is a familiar drop-in for existing HttpClient users. +/// Top-level configuration for a named TurboHTTP client. +/// Set via AddTurboHttpClient(name, o => { … }) in . +/// Contains per-protocol sub-options (, , ) +/// as well as shared transport, TLS, proxy, and pool settings. /// public sealed class TurboClientOptions { @@ -30,25 +42,25 @@ public sealed class TurboClientOptions public Uri? BaseAddress { get; set; } /// HTTP/1.x-specific configuration. - public Http1Options Http1 { get; init; } = new(); + public Http1ClientOptions Http1 { get; init; } = new(); /// HTTP/2-specific configuration. - public Http2Options Http2 { get; init; } = new(); + public Http2ClientOptions Http2 { get; init; } = new(); /// HTTP/3-specific configuration. - public Http3Options Http3 { get; init; } = new(); + public Http3ClientOptions Http3 { get; init; } = new(); /// - /// Maximum response body size (in bytes) that will be buffered in memory. - /// Bodies larger than this are streamed. Default is 4 MB. + /// Maximum size (in bytes) of a streamed response body. Responses exceeding this limit are aborted. + /// means unlimited. Default is . /// - public long MaxBufferedBodySize { get; set; } = 4 * 1024 * 1024L; + public long? MaxStreamedResponseBodySize { get; set; } = null; /// - /// Maximum response body size (in bytes) when streaming. - /// Null means unlimited. Default is null. + /// Chunk size (in bytes) for streaming request body uploads. Larger values reduce allocation + /// overhead and syscalls but increase per-stream memory. Default is 16 KiB. /// - public long? MaxStreamedBodySize { get; set; } = null; + public int RequestBodyChunkSize { get; set; } = 16 * 1024; /// /// Timeout for establishing a new TCP connection. @@ -71,11 +83,11 @@ public sealed class TurboClientOptions public TimeSpan PooledConnectionLifetime { get; set; } = Timeout.InfiniteTimeSpan; /// - /// Maximum number of distinct endpoint substreams (identified by (scheme, host, port, version)) + /// Maximum number of distinct endpoints (identified by (scheme, host, port, version)) /// that may be active concurrently. Controls the ceiling for per-endpoint multiplexing and connection pooling. /// Must be at least 1. Default is 256. TurboHTTP-specific. /// - public uint MaxEndpointSubstreams { get; set; } = 256; + public uint MaxConcurrentEndpoints { get; set; } = 256; /// /// TLS protocol versions to enable. Defaults to , @@ -112,6 +124,20 @@ public sealed class TurboClientOptions /// public int? SocketReceiveBufferSize { get; set; } + /// + /// Size hint for the internal receive buffer in bytes. + /// Larger values reduce the number of read syscalls at the cost of memory. + /// Default is 64 KiB. + /// + public int ReceiveBufferHint { get; set; } = 64 * 1024; + + /// + /// Minimum segment size for the internal buffer pool in bytes. + /// Segments smaller than this are not returned to the pool. + /// Default is 16 KiB. + /// + public int MinimumSegmentSize { get; set; } = 16 * 1024; + /// /// Whether to route requests through a proxy. /// When and is set, requests are diff --git a/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs b/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs index 51cb1548b..23c712840 100644 --- a/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs +++ b/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs @@ -45,7 +45,7 @@ public static ITurboHttpClientBuilder AddTurboHttpClient(this IServiceCollection // across all registered clients. var optionsMonitor = provider.GetRequiredService>(); var maxSubstreams = provider.GetServices() - .Select(n => optionsMonitor.Get(n.Name).MaxEndpointSubstreams) + .Select(n => optionsMonitor.Get(n.Name).MaxConcurrentEndpoints) .DefaultIfEmpty(256u) .Max(); @@ -106,8 +106,11 @@ public static ITurboHttpClientBuilder AddTurboHttpClient(this IServiceC where TClient : class { var name = typeof(TClient).Name; - services.AddTransient(sp => - (TClient)sp.GetRequiredService().CreateClient(name)); + services.AddTransient(sp => + { + var client = sp.GetRequiredService().CreateClient(name); + return ActivatorUtilities.CreateInstance(sp, client); + }); return services.AddTurboHttpClient(name, configure); } @@ -129,11 +132,22 @@ public static ITurboHttpClientBuilder AddTurboHttpClient(this IS { var name = typeof(TClient).Name; services.AddTransient(sp => - (TClient)sp.GetRequiredService().CreateClient(name)); - services.AddTransient(sp => (TImpl)sp.GetRequiredService().CreateClient(name)); + { + var client = sp.GetRequiredService().CreateClient(name); + return ActivatorUtilities.CreateInstance(sp, client); + }); + services.AddTransient(sp => + { + var client = sp.GetRequiredService().CreateClient(name); + return ActivatorUtilities.CreateInstance(sp, client); + }); return services.AddTurboHttpClient(name, configure); } + /// + /// Creates the default (unnamed) from . + /// Equivalent to calling factory.CreateClient(string.Empty). + /// public static ITurboHttpClient CreateClient(this ITurboHttpClientFactory factory) { ArgumentNullException.ThrowIfNull(factory); @@ -144,7 +158,7 @@ public static ITurboHttpClient CreateClient(this ITurboHttpClientFactory factory /// /// DI marker registered once per AddTurboHttpClient() call. /// Resolved via IServiceProvider.GetServices<TurboHttpClientName>() -/// to determine the maximum +/// to determine the maximum /// across all registered clients for dispatcher thread sizing. /// internal sealed record TurboHttpClientName(string Name); diff --git a/src/TurboHTTP/Client/TurboHandler.cs b/src/TurboHTTP/Client/TurboHandler.cs index e21a9c759..abca0dca2 100644 --- a/src/TurboHTTP/Client/TurboHandler.cs +++ b/src/TurboHTTP/Client/TurboHandler.cs @@ -1,10 +1,25 @@ namespace TurboHTTP.Client; +/// +/// Base class for pipeline handlers that inspect or transform requests and responses. +/// Override and/or to add +/// cross-cutting behavior such as authentication, logging, or header injection. +/// Register handlers via AddHandler<T> or UseRequest/UseResponse +/// on an ; handlers run in FIFO registration order. +/// public abstract class TurboHandler { + /// + /// Inspects or transforms before it is sent. + /// The default implementation returns unchanged. + /// public virtual HttpRequestMessage ProcessRequest(HttpRequestMessage request) => request; + /// + /// Inspects or transforms after it is received. + /// The default implementation returns unchanged. + /// public virtual HttpResponseMessage ProcessResponse(HttpRequestMessage original, HttpResponseMessage response) => response; } diff --git a/src/TurboHTTP/Client/TurboHttpClient.cs b/src/TurboHTTP/Client/TurboHttpClient.cs index 45fe884b5..bdb52315c 100644 --- a/src/TurboHTTP/Client/TurboHttpClient.cs +++ b/src/TurboHTTP/Client/TurboHttpClient.cs @@ -7,6 +7,10 @@ namespace TurboHTTP.Client; +/// +/// Default implementation backed by an Akka Streams pipeline. +/// Instances are created by — do not instantiate directly. +/// public sealed class TurboHttpClient : ITurboHttpClient { private static readonly int MaxPooledCts = Math.Max(Environment.ProcessorCount * 4, 64); @@ -28,7 +32,10 @@ public sealed class TurboHttpClient : ITurboHttpClient private readonly ICredentials? _credentials; private readonly bool _preAuthenticate; + private readonly bool _useProxy; + private readonly IWebProxy? _proxy; + /// public Uri? BaseAddress { get => _baseAddress; @@ -39,8 +46,10 @@ public Uri? BaseAddress } } + /// public HttpRequestHeaders DefaultRequestHeaders => _defaultHeadersHolder.Headers; + /// public Version DefaultRequestVersion { get => _defaultRequestVersion; @@ -51,6 +60,7 @@ public Version DefaultRequestVersion } } + /// public HttpVersionPolicy DefaultVersionPolicy { get => _defaultVersionPolicy; @@ -61,6 +71,7 @@ public HttpVersionPolicy DefaultVersionPolicy } } + /// public TimeSpan Timeout { get => _timeout; @@ -71,8 +82,10 @@ public TimeSpan Timeout } } + /// public ChannelWriter Requests { get; } + /// public ChannelReader Responses { get; } internal Guid ConsumerId => _consumerRegistration.ConsumerId; @@ -88,7 +101,9 @@ private void UpdateCachedOptions() _defaultVersionPolicy, _timeout, _credentials, - _preAuthenticate); + _preAuthenticate, + _useProxy, + _proxy); } internal TurboHttpClient( @@ -103,6 +118,8 @@ internal TurboHttpClient( _timeout = options.Timeout; _credentials = options.Credentials; _preAuthenticate = options.PreAuthenticate; + _useProxy = options.UseProxy; + _proxy = options.Proxy; foreach (var header in options.DefaultRequestHeaders) { _defaultHeadersHolder.Headers.TryAddWithoutValidation(header.Key, header.Value); @@ -114,6 +131,7 @@ internal TurboHttpClient( _consumerRegistration = consumerRegistration; } + /// public async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { ThrowIfDisposed(); @@ -126,6 +144,10 @@ public async Task SendAsync(HttpRequestMessage request, Can _pendingTcs.TryAdd(pending, 0); + var effectiveTimeout = request.Options.TryGetValue(OptionsKey.TimeoutKey, out var perRequestTimeout) + ? perRequestTimeout + : Timeout; + try { try @@ -137,7 +159,7 @@ public async Task SendAsync(HttpRequestMessage request, Can throw CreateClientDisposedException(); } - if (Timeout == System.Threading.Timeout.InfiniteTimeSpan && !cancellationToken.CanBeCanceled) + if (effectiveTimeout == System.Threading.Timeout.InfiniteTimeSpan && !cancellationToken.CanBeCanceled) { return await pending.GetValueTask(); } @@ -158,9 +180,11 @@ public async Task SendAsync(HttpRequestMessage request, Can cts = new CancellationTokenSource(); } + request.SetCancellationToken(cts.Token); + try { - cts.CancelAfter(Timeout); + cts.CancelAfter(effectiveTimeout); await using (cts.Token.UnsafeRegister( static (state, ct) => ((PendingRequest)state!).TrySetCanceled(ct), pending)) @@ -192,6 +216,7 @@ public async Task SendAsync(HttpRequestMessage request, Can } } + /// Disposes the client, cancels all pending requests, and releases the consumer registration. public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) @@ -211,6 +236,7 @@ public void Dispose() } } + /// public void CancelPendingRequests() { foreach (var pending in _pendingTcs.Keys) @@ -218,6 +244,11 @@ public void CancelPendingRequests() pending.TrySetCanceled(); _pendingTcs.TryRemove(pending, out _); } + + while (Responses.TryRead(out var stale)) + { + stale.Dispose(); + } } private void ThrowIfDisposed() diff --git a/src/TurboHTTP/Client/TurboHttpClientBuilderExtensions.cs b/src/TurboHTTP/Client/TurboHttpClientBuilderExtensions.cs index b63fa9721..394a64a80 100644 --- a/src/TurboHTTP/Client/TurboHttpClientBuilderExtensions.cs +++ b/src/TurboHTTP/Client/TurboHttpClientBuilderExtensions.cs @@ -4,8 +4,15 @@ namespace TurboHTTP.Client; +/// +/// Fluent extension methods for configuring an with +/// cookies, caching, retries, redirects, compression, Expect-100-Continue, and custom handlers. +/// public static class TurboHttpClientBuilderExtensions { + /// + /// Enables cookie handling for this client using an in-memory . + /// public static ITurboHttpClientBuilder WithCookies(this ITurboHttpClientBuilder builder) { builder.Services.Configure(builder.Name, d => @@ -16,6 +23,9 @@ public static ITurboHttpClientBuilder WithCookies(this ITurboHttpClientBuilder b return builder; } + /// + /// Enables cookie handling for this client using the provided . + /// public static ITurboHttpClientBuilder WithCookies(this ITurboHttpClientBuilder builder, ICookieStore store) { builder.Services.Configure(builder.Name, d => @@ -26,6 +36,9 @@ public static ITurboHttpClientBuilder WithCookies(this ITurboHttpClientBuilder b return builder; } + /// + /// Enables response caching using an in-memory store. Optionally configure via . + /// public static ITurboHttpClientBuilder WithCache(this ITurboHttpClientBuilder builder, Action? configure = null) { @@ -38,6 +51,9 @@ public static ITurboHttpClientBuilder WithCache(this ITurboHttpClientBuilder bui return builder; } + /// + /// Enables response caching using the provided . Optionally configure via . + /// public static ITurboHttpClientBuilder WithCache(this ITurboHttpClientBuilder builder, ICacheStore store, Action? configure = null) { @@ -51,6 +67,9 @@ public static ITurboHttpClientBuilder WithCache(this ITurboHttpClientBuilder bui return builder; } + /// + /// Enables automatic request retries. Optionally configure via . + /// public static ITurboHttpClientBuilder WithRetry(this ITurboHttpClientBuilder builder, Action? configure = null) { @@ -63,6 +82,9 @@ public static ITurboHttpClientBuilder WithRetry(this ITurboHttpClientBuilder bui return builder; } + /// + /// Enables automatic redirect following. Optionally configure via . + /// public static ITurboHttpClientBuilder WithRedirect(this ITurboHttpClientBuilder builder, Action? configure = null) { @@ -76,12 +98,18 @@ public static ITurboHttpClientBuilder WithRedirect(this ITurboHttpClientBuilder return builder; } + /// + /// Enables or disables automatic decompression of response bodies. Default is true. + /// public static ITurboHttpClientBuilder WithDecompression(this ITurboHttpClientBuilder builder, bool enabled = true) { builder.Services.Configure(builder.Name, d => { d.AutomaticDecompression = enabled; }); return builder; } + /// + /// Enables request body compression. Optionally configure the encoding and minimum body size via . + /// public static ITurboHttpClientBuilder WithRequestCompression( this ITurboHttpClientBuilder builder, Action? configure = null) { @@ -95,6 +123,10 @@ public static ITurboHttpClientBuilder WithRequestCompression( return builder; } + /// + /// Enables Expect: 100-continue negotiation for large request bodies. + /// Optionally configure the minimum body size threshold via . + /// public static ITurboHttpClientBuilder WithExpectContinue( this ITurboHttpClientBuilder builder, Action? configure = null) { diff --git a/src/TurboHTTP/Client/TurboHttpClientFactory.cs b/src/TurboHTTP/Client/TurboHttpClientFactory.cs index 7b8b7b746..d561d92d7 100644 --- a/src/TurboHTTP/Client/TurboHttpClientFactory.cs +++ b/src/TurboHTTP/Client/TurboHttpClientFactory.cs @@ -97,7 +97,9 @@ private PipelineDescriptor BuildPipeline(TurboClientOptions clientOptions, Turbo CachePolicy: descriptor.CachePolicy, Handlers: middlewares, AutomaticDecompression: descriptor.AutomaticDecompression, - AltSvcCache: altSvcCache); + AltSvcCache: altSvcCache, + UseProxy: clientOptions.UseProxy, + Proxy: clientOptions.Proxy); } private static TurboRequestOptions CreateRequestOptions(TurboClientOptions clientOptions) @@ -109,7 +111,9 @@ private static TurboRequestOptions CreateRequestOptions(TurboClientOptions clien DefaultVersionPolicy: HttpVersionPolicy.RequestVersionOrLower, Timeout: TimeSpan.FromSeconds(60), Credentials: clientOptions.Credentials, - PreAuthenticate: clientOptions.PreAuthenticate); + PreAuthenticate: clientOptions.PreAuthenticate, + UseProxy: clientOptions.UseProxy, + Proxy: clientOptions.Proxy); } private void ThrowIfDisposed() diff --git a/src/TurboHTTP/Diagnostics/HexDumpFormatter.cs b/src/TurboHTTP/Diagnostics/HexDumpFormatter.cs deleted file mode 100644 index 38565bd7b..000000000 --- a/src/TurboHTTP/Diagnostics/HexDumpFormatter.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Text; - -namespace TurboHTTP.Diagnostics; - -internal static class HexDumpFormatter -{ - private const int BytesPerLine = 16; - private const int FirstGroupSize = 8; - - public static string Format(ReadOnlySpan data) - { - if (data.IsEmpty) - { - return string.Empty; - } - - var lineCount = (data.Length + BytesPerLine - 1) / BytesPerLine; - var sb = new StringBuilder(lineCount * 78); - - for (var lineOffset = 0; lineOffset < data.Length; lineOffset += BytesPerLine) - { - if (lineOffset > 0) - { - sb.AppendLine(); - } - - var lineLength = Math.Min(BytesPerLine, data.Length - lineOffset); - var line = data.Slice(lineOffset, lineLength); - - sb.Append(lineOffset.ToString("X8")); - sb.Append(" "); - - for (var i = 0; i < BytesPerLine; i++) - { - if (i == FirstGroupSize) - { - sb.Append(' '); - } - - if (i < lineLength) - { - sb.Append(line[i].ToString("X2")); - sb.Append(' '); - } - else - { - sb.Append(" "); - } - } - - sb.Append(' '); - - for (var i = 0; i < lineLength; i++) - { - var b = line[i]; - sb.Append(b is >= 0x20 and < 0x7F ? (char)b : '.'); - } - } - - return sb.ToString(); - } -} diff --git a/src/TurboHTTP/Diagnostics/LoggerTraceListener.cs b/src/TurboHTTP/Diagnostics/LoggerTraceListener.cs index f2a893ff0..f73a9a638 100644 --- a/src/TurboHTTP/Diagnostics/LoggerTraceListener.cs +++ b/src/TurboHTTP/Diagnostics/LoggerTraceListener.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.Logging; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; namespace TurboHTTP.Diagnostics; @@ -54,7 +54,7 @@ private static LogLevel MapLevel(TraceLevel level) TraceLevel.Info => LogLevel.Information, TraceLevel.Warning => LogLevel.Warning, TraceLevel.Error => LogLevel.Error, - _ => LogLevel.None, + _ => LogLevel.None }; } } diff --git a/src/TurboHTTP/Diagnostics/TurboHttpInstrumentationExtensions.cs b/src/TurboHTTP/Diagnostics/TurboClientInstrumentationExtensions.cs similarity index 90% rename from src/TurboHTTP/Diagnostics/TurboHttpInstrumentationExtensions.cs rename to src/TurboHTTP/Diagnostics/TurboClientInstrumentationExtensions.cs index d21efad04..3ec03773d 100644 --- a/src/TurboHTTP/Diagnostics/TurboHttpInstrumentationExtensions.cs +++ b/src/TurboHTTP/Diagnostics/TurboClientInstrumentationExtensions.cs @@ -1,23 +1,24 @@ using System.Diagnostics; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; +using TurboHTTP.Protocol; namespace TurboHTTP.Diagnostics; -internal static class TurboHttpInstrumentationExtensions +internal static class TurboClientInstrumentationExtensions { internal static readonly HttpRequestOptionsKey RequestActivityKey = new("TurboHTTP.RequestActivity"); private static readonly HashSet StandardMethods = new(StringComparer.OrdinalIgnoreCase) { - "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH" + WellKnownHeaders.Get, WellKnownHeaders.Head, WellKnownHeaders.Post, WellKnownHeaders.Put, WellKnownHeaders.Delete, WellKnownHeaders.Connect, WellKnownHeaders.Options, WellKnownHeaders.Trace, WellKnownHeaders.Patch }; public static bool IsHttpTracingActive(this ServusTrace trace) { return trace.Source.HasListeners() - || Servus.Core.Servus.Metrics.RequestCount().Enabled - || Servus.Core.Servus.Metrics.RequestDuration().Enabled; + || Servus.Senf.Metrics.RequestCount().Enabled + || Servus.Senf.Metrics.RequestDuration().Enabled; } public static Activity? StartRequest(this ServusTrace trace, HttpRequestMessage request) @@ -31,7 +32,7 @@ public static bool IsHttpTracingActive(this ServusTrace trace) var method = request.Method.Method; var activity = trace.Source.StartActivity( - "TurboHTTP.Request", + "TurboHTTP.ClientRequest", ActivityKind.Client); if (activity is null) diff --git a/src/TurboHTTP/Diagnostics/TurboHttpMetricsExtensions.cs b/src/TurboHTTP/Diagnostics/TurboClientMetricsExtensions.cs similarity index 96% rename from src/TurboHTTP/Diagnostics/TurboHttpMetricsExtensions.cs rename to src/TurboHTTP/Diagnostics/TurboClientMetricsExtensions.cs index 31a637675..791443122 100644 --- a/src/TurboHTTP/Diagnostics/TurboHttpMetricsExtensions.cs +++ b/src/TurboHTTP/Diagnostics/TurboClientMetricsExtensions.cs @@ -1,9 +1,9 @@ using System.Diagnostics.Metrics; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; namespace TurboHTTP.Diagnostics; -internal static class TurboHttpMetricsExtensions +internal static class TurboClientMetricsExtensions { private static Counter? _requestCount; private static Histogram? _requestDuration; diff --git a/src/TurboHTTP/Diagnostics/TurboServerInstrumentationExtensions.cs b/src/TurboHTTP/Diagnostics/TurboServerInstrumentationExtensions.cs index 47e957a32..8d2691d88 100644 --- a/src/TurboHTTP/Diagnostics/TurboServerInstrumentationExtensions.cs +++ b/src/TurboHTTP/Diagnostics/TurboServerInstrumentationExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; +using TurboHTTP.Protocol; namespace TurboHTTP.Diagnostics; @@ -7,15 +8,15 @@ internal static class TurboServerInstrumentationExtensions { private static readonly HashSet StandardMethods = new(StringComparer.OrdinalIgnoreCase) { - "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH" + WellKnownHeaders.Get, WellKnownHeaders.Head, WellKnownHeaders.Post, WellKnownHeaders.Put, WellKnownHeaders.Delete, WellKnownHeaders.Connect, WellKnownHeaders.Options, WellKnownHeaders.Trace, WellKnownHeaders.Patch }; public static bool IsServerTracingActive(this ServusTrace trace) { return trace.Source.HasListeners() - || Servus.Core.Servus.Metrics.ActiveConnections().Enabled - || Servus.Core.Servus.Metrics.ServerActiveRequests().Enabled - || Servus.Core.Servus.Metrics.ServerRequestDuration().Enabled; + || Servus.Senf.Metrics.ActiveConnections().Enabled + || Servus.Senf.Metrics.ServerActiveRequests().Enabled + || Servus.Senf.Metrics.ServerRequestDuration().Enabled; } public static Activity? StartConnectionActivity(this ServusTrace trace, string serverAddress, int serverPort, string networkTransport) @@ -52,16 +53,23 @@ public static void StopConnectionActivity(this ServusTrace _, Activity activity, activity.Stop(); } - public static Activity? StartRequestActivity(this ServusTrace trace, string method, string path, string scheme) + public static Activity? StartRequestActivity(this ServusTrace trace, string method, string path, string scheme, + string? traceParent = null, string? traceState = null) { if (!trace.Source.HasListeners()) { return null; } - var activity = trace.Source.StartActivity( - "TurboHTTP.ServerRequest", - ActivityKind.Server); + ActivityContext parentContext = default; + if (traceParent is not null && ActivityContext.TryParse(traceParent, traceState, out var parsed)) + { + parentContext = parsed; + } + + var activity = parentContext != default + ? trace.Source.StartActivity("TurboHTTP.ServerRequest", ActivityKind.Server, parentContext) + : trace.Source.StartActivity("TurboHTTP.ServerRequest", ActivityKind.Server); if (activity is null) { diff --git a/src/TurboHTTP/Diagnostics/TurboServerMetricsExtensions.cs b/src/TurboHTTP/Diagnostics/TurboServerMetricsExtensions.cs index 2d50637b8..8657ce8f3 100644 --- a/src/TurboHTTP/Diagnostics/TurboServerMetricsExtensions.cs +++ b/src/TurboHTTP/Diagnostics/TurboServerMetricsExtensions.cs @@ -1,5 +1,5 @@ using System.Diagnostics.Metrics; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; namespace TurboHTTP.Diagnostics; @@ -21,7 +21,7 @@ internal static class TurboServerMetricsExtensions public static UpDownCounter ActiveConnections(this ServusMetrics metrics) { return _activeConnections ??= metrics.Meter.CreateUpDownCounter( - "kestrel.active_connections", + "turbo.server.active_connections", unit: "{connection}", description: "Number of connections that are currently active on the server."); } @@ -29,7 +29,7 @@ public static UpDownCounter ActiveConnections(this ServusMetrics metrics) public static Histogram ConnectionDuration(this ServusMetrics metrics) { return _connectionDuration ??= metrics.Meter.CreateHistogram( - "kestrel.connection.duration", + "turbo.server.connection.duration", unit: "s", description: "The duration of connections on the server."); } @@ -37,7 +37,7 @@ public static Histogram ConnectionDuration(this ServusMetrics metrics) public static Counter RejectedConnections(this ServusMetrics metrics) { return _rejectedConnections ??= metrics.Meter.CreateCounter( - "kestrel.rejected_connections", + "turbo.server.rejected_connections", unit: "{connection}", description: "Number of connections rejected by the server."); } @@ -45,7 +45,7 @@ public static Counter RejectedConnections(this ServusMetrics metrics) public static Histogram TlsHandshakeDuration(this ServusMetrics metrics) { return _tlsHandshakeDuration ??= metrics.Meter.CreateHistogram( - "kestrel.tls_handshake.duration", + "turbo.server.tls_handshake.duration", unit: "s", description: "The duration of TLS handshakes on the server."); } @@ -53,7 +53,7 @@ public static Histogram TlsHandshakeDuration(this ServusMetrics metrics) public static UpDownCounter ActiveTlsHandshakes(this ServusMetrics metrics) { return _activeTlsHandshakes ??= metrics.Meter.CreateUpDownCounter( - "kestrel.active_tls_handshakes", + "turbo.server.active_tls_handshakes", unit: "{handshake}", description: "Number of TLS handshakes that are currently in progress on the server."); } diff --git a/src/TurboHTTP/Diagnostics/TurboTraceExtensions.cs b/src/TurboHTTP/Diagnostics/TurboTraceExtensions.cs index 617f4ac04..e22a1364b 100644 --- a/src/TurboHTTP/Diagnostics/TurboTraceExtensions.cs +++ b/src/TurboHTTP/Diagnostics/TurboTraceExtensions.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -using Servus.Core.Diagnostics; +using Servus.Diagnostics; namespace TurboHTTP.Diagnostics; @@ -11,6 +11,11 @@ namespace TurboHTTP.Diagnostics; /// public static class TurboTraceExtensions { + /// + /// Registers a backed by as the + /// Servus trace sink. Calls to the internal tracing API are forwarded to the standard + /// Microsoft.Extensions.Logging pipeline at the mapped log level. + /// public static IServiceCollection AddTurboLoggerTracing( this IServiceCollection services, TraceLevel minimumLevel = TraceLevel.Debug, @@ -20,12 +25,17 @@ public static IServiceCollection AddTurboLoggerTracing( { var loggerFactory = sp.GetRequiredService(); var listener = new LoggerTraceListener(loggerFactory); - Servus.Core.Servus.Tracing.Configure(listener, minimumLevel, categoryFilter); + Servus.Senf.Tracing.Configure(listener, minimumLevel, categoryFilter); return listener; }); return services; } + /// + /// Registers a caller-supplied as the Servus trace sink. + /// Use this overload when you already have a custom listener and want to configure its minimum + /// level and optional category filter without creating a logger-backed listener. + /// public static IServiceCollection AddTurboTracing( this IServiceCollection services, IServusTraceListener listener, @@ -33,32 +43,36 @@ public static IServiceCollection AddTurboTracing( Func? categoryFilter = null) { ArgumentNullException.ThrowIfNull(listener); - Servus.Core.Servus.Tracing.Configure(listener, minimumLevel, categoryFilter); + Servus.Senf.Tracing.Configure(listener, minimumLevel, categoryFilter); services.AddSingleton(listener); return services; } + /// Adds the TurboHTTP client activity source to the OpenTelemetry tracer provider. public static TracerProviderBuilder AddTurboHttpInstrumentation(this TracerProviderBuilder builder) { return builder - .AddSource(Servus.Core.Servus.Tracing.Source.Name); + .AddSource(Servus.Senf.Tracing.Source.Name); } + /// Adds the TurboHTTP client meter to the OpenTelemetry meter provider. public static MeterProviderBuilder AddTurboHttpInstrumentation(this MeterProviderBuilder builder) { return builder - .AddMeter(Servus.Core.Servus.Metrics.Meter.Name); + .AddMeter(Servus.Senf.Metrics.Meter.Name); } + /// Adds the TurboHTTP server activity source to the OpenTelemetry tracer provider. public static TracerProviderBuilder AddTurboServerInstrumentation(this TracerProviderBuilder builder) { return builder - .AddSource(Servus.Core.Servus.Tracing.Source.Name); + .AddSource(Servus.Senf.Tracing.Source.Name); } + /// Adds the TurboHTTP server meter to the OpenTelemetry meter provider. public static MeterProviderBuilder AddTurboServerInstrumentation(this MeterProviderBuilder builder) { return builder - .AddMeter(Servus.Core.Servus.Metrics.Meter.Name); + .AddMeter(Servus.Senf.Metrics.Meter.Name); } } \ No newline at end of file diff --git a/src/TurboHTTP/Features/Caching/Cache.cs b/src/TurboHTTP/Features/Caching/Cache.cs index 1f8856dfe..7eaa14d49 100644 --- a/src/TurboHTTP/Features/Caching/Cache.cs +++ b/src/TurboHTTP/Features/Caching/Cache.cs @@ -11,6 +11,7 @@ internal sealed class Cache(ICacheStore store, CachePolicy? policy = null) private readonly LinkedList _lruOrder = []; private readonly Dictionary> _lruIndex = new(); + private long _totalBytes; // primaryKey → list of (compoundKey, varyValues) for variant tracking private readonly Dictionary varyValues)>> @@ -72,7 +73,7 @@ public void Put( if (_policy.SharedCache) { - if (response.Headers.TryGetValues("Cache-Control", out var ccVals)) + if (response.Headers.TryGetValues(WellKnownHeaders.CacheControl, out var ccVals)) { var cc = CacheControlParser.Parse(string.Join(", ", ccVals)); if (cc is { Private: true, PrivateFields: null }) @@ -101,18 +102,26 @@ public void Put( RemoveMatching(primaryKey, storeEntry.VaryRequestValues); - // LRU eviction - while (_lruOrder.Count >= _policy.MaxEntries) + // LRU eviction: by count and total memory budget + while (_lruOrder.Count >= _policy.MaxEntries + || (_totalBytes + bodyLength > _policy.MaxTotalBytes && _lruOrder.Count > 0)) { var lastNode = _lruOrder.Last!; var lastKey = lastNode.Value; _lruOrder.RemoveLast(); _lruIndex.Remove(lastKey); - store.Remove(lastKey); + + if (store.TryGet(lastKey, out var evicted)) + { + _totalBytes -= evicted.Body.Length; + store.Remove(lastKey); + evicted.Dispose(); + } RemoveFromVariantIndex(lastKey); } + _totalBytes += bodyLength; store.Set(compoundKey, storeEntry); var lruNode = _lruOrder.AddFirst(compoundKey); _lruIndex[compoundKey] = lruNode; @@ -138,7 +147,12 @@ public void Invalidate(Uri uri) foreach (var (compoundKey, _) in variants.ToList()) { - store.Remove(compoundKey); + if (store.TryGet(compoundKey, out var evicted)) + { + _totalBytes -= evicted.Body.Length; + store.Remove(compoundKey); + evicted.Dispose(); + } if (_lruIndex.TryGetValue(compoundKey, out var node)) { @@ -210,6 +224,7 @@ public void Clear() _lruOrder.Clear(); _lruIndex.Clear(); _variantIndex.Clear(); + _totalBytes = 0; } public static (IMemoryOwner owner, int length) RentBody(ReadOnlySpan source) @@ -300,7 +315,7 @@ private static CacheStoreEntry BuildStoreEntry( } var varyNames = new List(); - if (response.Headers.TryGetValues("Vary", out var varyValues)) + if (response.Headers.TryGetValues(WellKnownHeaders.Vary, out var varyValues)) { foreach (var v in varyValues) { @@ -393,6 +408,7 @@ private void RemoveMatching(string primaryKey, IReadOnlyDictionary +/// A pooled byte buffer holding the body of a cached HTTP response. +/// Wraps a rented and exposes read-only views. +/// Dispose to return the underlying buffer to the pool. +/// public sealed class CacheBody : IDisposable { private IMemoryOwner? _owner; @@ -12,14 +17,19 @@ internal CacheBody(IMemoryOwner owner, int length) Length = length; } + /// Gets a read-only span over the cached body bytes. Returns an empty span after disposal. public ReadOnlySpan Span => _owner is not null ? _owner.Memory.Span[..Length] : []; + /// Gets a read-only memory region over the cached body bytes. Returns after disposal. public ReadOnlyMemory Memory => _owner?.Memory[..Length] ?? ReadOnlyMemory.Empty; + /// Gets the number of valid bytes in the cached body. public int Length { get; } + /// Gets a value indicating whether the cached body contains no bytes. public bool IsEmpty => Length == 0; + /// Returns the underlying buffer to the pool. Subsequent accesses to and return empty. public void Dispose() { Interlocked.Exchange(ref _owner, null)?.Dispose(); diff --git a/src/TurboHTTP/Features/Caching/CacheControlParser.cs b/src/TurboHTTP/Features/Caching/CacheControlParser.cs index e327c15c7..d5f28838b 100644 --- a/src/TurboHTTP/Features/Caching/CacheControlParser.cs +++ b/src/TurboHTTP/Features/Caching/CacheControlParser.cs @@ -1,3 +1,5 @@ +using TurboHTTP.Protocol; + namespace TurboHTTP.Features.Caching; /// @@ -77,12 +79,12 @@ internal static class CacheControlParser } // Case-insensitive directive matching (RFC 9111 §5.2) - if (name.Equals("no-cache", StringComparison.OrdinalIgnoreCase)) + if (name.Equals(WellKnownHeaders.NoCache.Name, StringComparison.OrdinalIgnoreCase)) { noCache = true; noCacheFields = ParseFieldList(value); } - else if (name.Equals("no-store", StringComparison.OrdinalIgnoreCase)) + else if (name.Equals(WellKnownHeaders.NoStore.Name, StringComparison.OrdinalIgnoreCase)) { noStore = true; } @@ -102,11 +104,11 @@ internal static class CacheControlParser { proxyRevalidate = true; } - else if (name.Equals("public", StringComparison.OrdinalIgnoreCase)) + else if (name.Equals(WellKnownHeaders.PublicDirective.Name, StringComparison.OrdinalIgnoreCase)) { isPublic = true; } - else if (name.Equals("private", StringComparison.OrdinalIgnoreCase)) + else if (name.Equals(WellKnownHeaders.PrivateDirective.Name, StringComparison.OrdinalIgnoreCase)) { isPrivate = true; privateFields = ParseFieldList(value); diff --git a/src/TurboHTTP/Features/Caching/CachePolicy.cs b/src/TurboHTTP/Features/Caching/CachePolicy.cs index 9157e11cc..2467a0097 100644 --- a/src/TurboHTTP/Features/Caching/CachePolicy.cs +++ b/src/TurboHTTP/Features/Caching/CachePolicy.cs @@ -17,6 +17,13 @@ internal sealed record CachePolicy /// public long MaxBodyBytes { get; init; } = 52_428_800; // 50 MiB + /// + /// Maximum total size (in bytes) of all cached response bodies combined. + /// When this limit is exceeded, the least-recently-used entries are evicted until + /// the total drops below the limit. Default is 256 MiB. + /// + public long MaxTotalBytes { get; init; } = 256 * 1024 * 1024; + /// /// When true the cache acts as a shared (proxy) cache: s-maxage is honoured, /// private responses are not stored. diff --git a/src/TurboHTTP/Features/Caching/CacheStoreEntry.cs b/src/TurboHTTP/Features/Caching/CacheStoreEntry.cs index 683fcfa47..a3bbdad24 100644 --- a/src/TurboHTTP/Features/Caching/CacheStoreEntry.cs +++ b/src/TurboHTTP/Features/Caching/CacheStoreEntry.cs @@ -1,40 +1,104 @@ namespace TurboHTTP.Features.Caching; +/// +/// Mutable store record that owns a cached HTTP response and its body buffer. +/// Passed into and out of . Dispose to release the body buffer. +/// public sealed class CacheStoreEntry : IDisposable { + /// Gets the cached HTTP response message. public required HttpResponseMessage Response { get; init; } + + /// Gets the pooled buffer containing the cached response body. public required CacheBody Body { get; init; } + + /// Gets the time at which the originating request was sent (RFC 9111 §4.2.3). public required DateTimeOffset RequestTime { get; init; } + + /// Gets the time at which the response was received (RFC 9111 §4.2.3). public required DateTimeOffset ResponseTime { get; init; } + + /// Gets the ETag validator from the cached response, or if absent. public string? ETag { get; init; } + + /// Gets the Last-Modified date from the cached response, or if absent. public DateTimeOffset? LastModified { get; init; } + + /// Gets the Expires date from the cached response, or if absent. public DateTimeOffset? Expires { get; init; } + + /// Gets the Date header value from the cached response, or if absent. public DateTimeOffset? Date { get; init; } + + /// Gets the Age header value in seconds from the cached response, or if absent. public int? AgeSeconds { get; init; } + + /// Gets the parsed Cache-Control directives from the cached response, or if absent. public CacheControlStoreEntry? CacheControl { get; init; } + + /// Gets the header names from the Vary field of the cached response. Defaults to an empty array. public string[] VaryHeaderNames { get; init; } = []; + + /// Gets the request header values captured at store time for each Vary header name. Defaults to an empty dictionary. public Dictionary VaryRequestValues { get; init; } = new(); + /// Disposes the underlying body buffer. public void Dispose() => Body.Dispose(); } +/// +/// Serializable snapshot of Cache-Control directives stored alongside a cached response. +/// Mirrors but uses plain arrays instead of +/// to simplify persistence and equality semantics. +/// public sealed record CacheControlStoreEntry { + /// RFC 9111 §5.2.1.4 / §5.2.2.3 — no-cache directive. public bool NoCache { get; init; } + + /// RFC 9111 §5.2.1.5 / §5.2.2.4 — no-store directive. public bool NoStore { get; init; } + + /// RFC 9111 §5.2.1.6 / §5.2.2.5 — no-transform directive. public bool NoTransform { get; init; } + + /// RFC 9111 §5.2.1.1 / §5.2.2.1 — max-age value. public TimeSpan? MaxAge { get; init; } + + /// RFC 9111 §5.2.1.2 — max-stale value (request only). public TimeSpan? MaxStale { get; init; } + + /// RFC 9111 §5.2.1.3 — min-fresh value (request only). public TimeSpan? MinFresh { get; init; } + + /// RFC 9111 §5.2.1.7 — only-if-cached directive (request only). public bool OnlyIfCached { get; init; } + + /// RFC 9111 §5.2.2.9 — s-maxage value (response, shared cache only). public TimeSpan? SMaxAge { get; init; } + + /// RFC 9111 §5.2.2.2 — must-revalidate directive (response only). public bool MustRevalidate { get; init; } + + /// RFC 9111 §5.2.2.7 — proxy-revalidate directive (response only). public bool ProxyRevalidate { get; init; } + + /// RFC 9111 §5.2.2.8 — public directive (response only). public bool Public { get; init; } + + /// RFC 9111 §5.2.2.6 — private directive (response only). public bool Private { get; init; } + + /// RFC 8246 — immutable directive (response only). public bool Immutable { get; init; } + + /// RFC 9111 §5.2.2.3 — must-understand directive (response only). public bool MustUnderstand { get; init; } + + /// RFC 9111 §5.2.2.3 — field names from no-cache="…". Defaults to an empty array. public string[] NoCacheFields { get; init; } = []; + + /// RFC 9111 §5.2.2.6 — field names from private="…". Defaults to an empty array. public string[] PrivateFields { get; init; } = []; internal CacheControl ToCacheControl() => new() @@ -54,7 +118,7 @@ public sealed record CacheControlStoreEntry Immutable = Immutable, MustUnderstand = MustUnderstand, NoCacheFields = NoCacheFields, - PrivateFields = PrivateFields, + PrivateFields = PrivateFields }; internal static CacheControlStoreEntry FromCacheControl(CacheControl cc) => new() @@ -74,6 +138,6 @@ public sealed record CacheControlStoreEntry Immutable = cc.Immutable, MustUnderstand = cc.MustUnderstand, NoCacheFields = cc.NoCacheFields?.ToArray() ?? [], - PrivateFields = cc.PrivateFields?.ToArray() ?? [], + PrivateFields = cc.PrivateFields?.ToArray() ?? [] }; } \ No newline at end of file diff --git a/src/TurboHTTP/Features/Caching/CacheValidationRequestBuilder.cs b/src/TurboHTTP/Features/Caching/CacheValidationRequestBuilder.cs index 38fd0b57e..540b273df 100644 --- a/src/TurboHTTP/Features/Caching/CacheValidationRequestBuilder.cs +++ b/src/TurboHTTP/Features/Caching/CacheValidationRequestBuilder.cs @@ -37,7 +37,7 @@ public static HttpRequestMessage BuildConditionalRequest(HttpRequestMessage orig // RFC 9111 §4.3.1 — If-None-Match from ETag (preferred over If-Modified-Since) if (entry.ETag is not null) { - conditional.Headers.TryAddWithoutValidation("If-None-Match", entry.ETag); + conditional.Headers.TryAddWithoutValidation(WellKnownHeaders.IfNoneMatch, entry.ETag); } // RFC 9111 §4.3.1 — If-Modified-Since from Last-Modified @@ -114,7 +114,7 @@ public static HttpRequestMessage BuildHeadValidationRequest(HttpRequestMessage o // RFC 9111 §4.3.1 — If-None-Match from ETag if (entry.ETag is not null) { - head.Headers.TryAddWithoutValidation("If-None-Match", entry.ETag); + head.Headers.TryAddWithoutValidation(WellKnownHeaders.IfNoneMatch, entry.ETag); } // RFC 9111 §4.3.1 — If-Modified-Since from Last-Modified diff --git a/src/TurboHTTP/Features/Caching/ICacheEntry.cs b/src/TurboHTTP/Features/Caching/ICacheEntry.cs index 92c342710..700e487b9 100644 --- a/src/TurboHTTP/Features/Caching/ICacheEntry.cs +++ b/src/TurboHTTP/Features/Caching/ICacheEntry.cs @@ -1,17 +1,44 @@ namespace TurboHTTP.Features.Caching; +/// +/// Read-only view of a cached HTTP response entry, including the response metadata, +/// body bytes, and freshness validators. Dispose to release the underlying body buffer. +/// public interface ICacheEntry : IDisposable { + /// Gets the cached HTTP response message. HttpResponseMessage Response { get; } + + /// Gets the cached response body as a read-only memory region. ReadOnlyMemory Body { get; } + + /// Gets the time at which the originating request was sent (RFC 9111 §4.2.3). DateTimeOffset RequestTime { get; } + + /// Gets the time at which the response was received (RFC 9111 §4.2.3). DateTimeOffset ResponseTime { get; } + + /// Gets the ETag validator from the cached response, or if absent. string? ETag { get; } + + /// Gets the Last-Modified date from the cached response, or if absent. DateTimeOffset? LastModified { get; } + + /// Gets the Expires date from the cached response, or if absent. DateTimeOffset? Expires { get; } + + /// Gets the Date header value from the cached response, or if absent. DateTimeOffset? Date { get; } + + /// Gets the Age header value in seconds from the cached response, or if absent. int? AgeSeconds { get; } + + /// Gets the parsed Cache-Control directives from the cached response, or if absent. CacheControl? CacheControl { get; } + + /// Gets the list of header names from the Vary field of the cached response. IReadOnlyList VaryHeaderNames { get; } + + /// Gets the request header values captured at store time for each Vary header name. IReadOnlyDictionary VaryRequestValues { get; } } diff --git a/src/TurboHTTP/Features/Caching/ICacheStore.cs b/src/TurboHTTP/Features/Caching/ICacheStore.cs index f96d0397f..4ae43cc18 100644 --- a/src/TurboHTTP/Features/Caching/ICacheStore.cs +++ b/src/TurboHTTP/Features/Caching/ICacheStore.cs @@ -2,10 +2,23 @@ namespace TurboHTTP.Features.Caching; +/// +/// Pluggable storage back-end for cached HTTP response entries. +/// Implementations are responsible for memory management; takes ownership +/// of the provided and / +/// must dispose evicted entries. +/// public interface ICacheStore : IDisposable { + /// Attempts to retrieve the entry stored under . Returns if found. bool TryGet(string key, [NotNullWhen(true)] out CacheStoreEntry? entry); + + /// Stores under , replacing any existing entry. void Set(string key, CacheStoreEntry entry); + + /// Removes and disposes the entry stored under . Returns if an entry was present. bool Remove(string key); + + /// Removes and disposes all stored entries. void Clear(); } \ No newline at end of file diff --git a/src/TurboHTTP/Features/Cookies/CookieJar.cs b/src/TurboHTTP/Features/Cookies/CookieJar.cs index 6dd7fe9d4..72e6dd12d 100644 --- a/src/TurboHTTP/Features/Cookies/CookieJar.cs +++ b/src/TurboHTTP/Features/Cookies/CookieJar.cs @@ -3,10 +3,8 @@ namespace TurboHTTP.Features.Cookies; -internal sealed class CookieJar +internal sealed class CookieJar(ICookieStore store) { - private readonly ICookieStore _store; - private readonly List _applicable = []; public CookieJar() @@ -14,11 +12,6 @@ public CookieJar() { } - public CookieJar(ICookieStore store) - { - _store = store; - } - public void ProcessResponse(Uri requestUri, HttpResponseMessage response) { ArgumentNullException.ThrowIfNull(requestUri); @@ -39,16 +32,31 @@ public void ProcessResponse(Uri requestUri, HttpResponseMessage response) continue; } - _store.Remove(entry.Name, entry.Domain, entry.Path); + store.Remove(entry.Name, entry.Domain, entry.Path); if (!IsExpired(entry, now)) { - _store.Add(ToStoreEntry(entry)); + store.Add(ToStoreEntry(entry)); } } } public void AddCookiesToRequest(Uri requestUri, ref HttpRequestMessage request) + => AddCookiesToRequest(requestUri, ref request, firstPartyContext: null, isSafeMethod: true); + + /// + /// Injects applicable cookies into , enforcing the SameSite attribute + /// relative to the request's first-party context (RFC 6265bis §5.8.3). + /// + /// + /// The site initiating the request. When the request is treated as first-party + /// (same-site), preserving the behavior of the simple two-argument overload. + /// + /// + /// Whether the request uses a safe, top-level-navigation method (GET/HEAD). SameSite=Lax cookies + /// are sent cross-site only when this is . + /// + public void AddCookiesToRequest(Uri requestUri, ref HttpRequestMessage request, Uri? firstPartyContext, bool isSafeMethod) { ArgumentNullException.ThrowIfNull(requestUri); ArgumentNullException.ThrowIfNull(request); @@ -57,10 +65,11 @@ public void AddCookiesToRequest(Uri requestUri, ref HttpRequestMessage request) var requestHost = requestUri.Host.ToLowerInvariant(); var requestPath = string.IsNullOrEmpty(requestUri.AbsolutePath) ? "/" : requestUri.AbsolutePath; var isHttps = requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase); + var isCrossSite = firstPartyContext is not null && !IsSameSite(requestUri, firstPartyContext); _applicable.Clear(); - foreach (var cookie in _store.GetAll()) + foreach (var cookie in store.GetAll()) { if (IsExpired(cookie, now)) { @@ -82,6 +91,11 @@ public void AddCookiesToRequest(Uri requestUri, ref HttpRequestMessage request) continue; } + if (isCrossSite && !SameSiteAllowsCrossSite(cookie.SameSite, isSafeMethod)) + { + continue; + } + _applicable.Add(cookie); } @@ -111,9 +125,44 @@ public void AddCookiesToRequest(Uri requestUri, ref HttpRequestMessage request) string.Join(WellKnownHeaders.SemiColonSpace, parts)); } - public int Count => _store.Count; + public int Count => store.Count; - public void Clear() => _store.Clear(); + public void Clear() => store.Clear(); + + /// + /// Whether SameSite permits a cookie on a cross-site request. + /// Strict never; Lax only on safe top-level navigations; None/Unspecified always. + /// + private static bool SameSiteAllowsCrossSite(SameSitePolicy policy, bool isSafeMethod) => policy switch + { + SameSitePolicy.Strict => false, + SameSitePolicy.Lax => isSafeMethod, + _ => true + }; + + /// + /// Two URIs are same-site when they share the same registrable domain (RFC 6265bis §5.2). + /// Uses a last-two-labels approximation; multi-level public suffixes (e.g. co.uk) are not + /// resolved because TurboHTTP does not bundle a Public Suffix List. + /// + internal static bool IsSameSite(Uri request, Uri firstParty) + => string.Equals( + RegistrableDomain(request.Host), + RegistrableDomain(firstParty.Host), + StringComparison.OrdinalIgnoreCase); + + internal static string RegistrableDomain(string host) + { + if (IsIpAddress(host)) + { + return host; + } + + var labels = host.Split('.'); + return labels.Length <= 2 + ? host + : string.Concat(labels[^2], ".", labels[^1]); + } internal static bool DomainMatches(string cookieDomain, bool isHostOnly, string requestHost) { diff --git a/src/TurboHTTP/Features/Cookies/CookieParser.cs b/src/TurboHTTP/Features/Cookies/CookieParser.cs index 8b03b7d06..0938d1c4e 100644 --- a/src/TurboHTTP/Features/Cookies/CookieParser.cs +++ b/src/TurboHTTP/Features/Cookies/CookieParser.cs @@ -1,4 +1,5 @@ using System.Globalization; +using TurboHTTP.Protocol; namespace TurboHTTP.Features.Cookies; @@ -89,7 +90,7 @@ internal static class CookieParser // RFC 6265 §5.2.4: Path attribute value pathAttr = string.IsNullOrEmpty(attrValue) ? null : attrValue; } - else if (attrName.Equals("Expires", StringComparison.OrdinalIgnoreCase)) + else if (attrName.Equals(WellKnownHeaders.Expires, StringComparison.OrdinalIgnoreCase)) { if (TryParseExpires(attrValue, out var expires)) { @@ -111,7 +112,7 @@ internal static class CookieParser "strict" => SameSitePolicy.Strict, "lax" => SameSitePolicy.Lax, "none" => SameSitePolicy.None, - _ => SameSitePolicy.Unspecified, + _ => SameSitePolicy.Unspecified }; } } @@ -194,7 +195,7 @@ private static string GetDefaultPath(Uri requestUri) "ddd, dd-MMM-yy HH:mm:ss 'GMT'", "ddd, dd MMM yy HH:mm:ss 'GMT'", "ddd, dd MMM yyyy HH:mm:ss", - "dddd, dd-MMM-yy HH:mm:ss 'GMT'", + "dddd, dd-MMM-yy HH:mm:ss 'GMT'" ]; private static bool TryParseExpires(string value, out DateTimeOffset result) diff --git a/src/TurboHTTP/Features/Cookies/CookieStoreEntry.cs b/src/TurboHTTP/Features/Cookies/CookieStoreEntry.cs index 9125be577..44d9a41a2 100644 --- a/src/TurboHTTP/Features/Cookies/CookieStoreEntry.cs +++ b/src/TurboHTTP/Features/Cookies/CookieStoreEntry.cs @@ -1,13 +1,36 @@ namespace TurboHTTP.Features.Cookies; +/// +/// Specifies the SameSite attribute of a cookie, controlling cross-site send behavior (RFC 6265bis §5.4). +/// public enum SameSitePolicy { + /// No SameSite attribute was present; the cookie is sent in all contexts. Unspecified, + + /// The cookie is sent only for same-site requests. Strict, + + /// The cookie is sent for same-site requests and safe (GET/HEAD) top-level cross-site navigations. Lax, - None, + + /// The cookie is sent in all contexts, including cross-site. Requires the Secure attribute. + None } +/// +/// Immutable snapshot of a cookie as persisted in an . +/// +/// The cookie name. +/// The cookie value. +/// The domain the cookie applies to, lowercased and without a leading dot. +/// The path scope of the cookie (always starts with '/'). +/// Absolute UTC expiry time, or for a session cookie. +/// When , the cookie is sent only over HTTPS. +/// When , the cookie is inaccessible to client-side scripts. +/// The SameSite policy controlling cross-site delivery. +/// When , the cookie was set without a Domain attribute and applies to the exact request host only. +/// UTC time at which the cookie was first stored. public sealed record CookieStoreEntry( string Name, string Value, diff --git a/src/TurboHTTP/Features/Cookies/ICookieStore.cs b/src/TurboHTTP/Features/Cookies/ICookieStore.cs index 49b24ad9d..b13236b31 100644 --- a/src/TurboHTTP/Features/Cookies/ICookieStore.cs +++ b/src/TurboHTTP/Features/Cookies/ICookieStore.cs @@ -1,10 +1,24 @@ namespace TurboHTTP.Features.Cookies; +/// +/// Pluggable storage back-end for cookie entries used by the cookie jar. +/// Implementations are not required to be thread-safe; the cookie jar accesses the +/// store on a single logical thread per request pipeline. +/// public interface ICookieStore { + /// Returns all stored cookie entries. IReadOnlyList GetAll(); + + /// Adds to the store. void Add(CookieStoreEntry entry); + + /// Removes the entry matching the given , , and triple. void Remove(string name, string domain, string path); + + /// Removes all stored cookie entries. void Clear(); + + /// Gets the number of cookie entries currently in the store. int Count { get; } } diff --git a/src/TurboHTTP/Internal/ClientCorrelationKeys.cs b/src/TurboHTTP/Internal/ClientCorrelationKeys.cs index ab86abbae..ee0e5a44c 100644 --- a/src/TurboHTTP/Internal/ClientCorrelationKeys.cs +++ b/src/TurboHTTP/Internal/ClientCorrelationKeys.cs @@ -1,3 +1,5 @@ +using System.Threading; + namespace TurboHTTP.Internal; internal static class OptionsKey @@ -5,4 +7,7 @@ internal static class OptionsKey internal static readonly HttpRequestOptionsKey ConsumerIdKey = new("TurboHTTP.ConsumerId"); internal static readonly HttpRequestOptionsKey Key = new("TurboHTTP.PendingRequest"); internal static readonly HttpRequestOptionsKey VersionKey = new("TurboHTTP.Version"); + internal static readonly HttpRequestOptionsKey TimeoutKey = new("TurboHTTP.RequestTimeout"); + internal static readonly HttpRequestOptionsKey FirstPartyContextKey = new("TurboHTTP.FirstPartyContext"); + internal static readonly HttpRequestOptionsKey CancellationTokenKey = new("TurboHTTP.CancellationToken"); } \ No newline at end of file diff --git a/src/TurboHTTP/Internal/DecompressingContent.cs b/src/TurboHTTP/Internal/DecompressingContent.cs index 01a8d7c55..e5838be4b 100644 --- a/src/TurboHTTP/Internal/DecompressingContent.cs +++ b/src/TurboHTTP/Internal/DecompressingContent.cs @@ -3,16 +3,9 @@ namespace TurboHTTP.Internal; -internal sealed class DecompressingContent : HttpContent +internal sealed class DecompressingContent(HttpContent inner, string encoding) : HttpContent { - private HttpContent? _inner; - private readonly string _encoding; - - public DecompressingContent(HttpContent inner, string encoding) - { - _inner = inner; - _encoding = encoding; - } + private HttpContent? _inner = inner; protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken ct) { @@ -20,7 +13,7 @@ protected override void SerializeToStream(Stream stream, TransportContext? conte using var source = inner.ReadAsStream(ct); try { - using var decompressor = ContentEncoding.CreateDecompressor(source, _encoding); + using var decompressor = ContentEncoding.CreateDecompressor(source, encoding); decompressor.CopyTo(stream); } catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or Protocol.HttpProtocolException) @@ -34,7 +27,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon await using var source = await inner.ReadAsStreamAsync().ConfigureAwait(false); try { - await using var decompressor = ContentEncoding.CreateDecompressor(source, _encoding); + await using var decompressor = ContentEncoding.CreateDecompressor(source, encoding); await decompressor.CopyToAsync(stream).ConfigureAwait(false); } catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or Protocol.HttpProtocolException) @@ -48,7 +41,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon await using var source = await inner.ReadAsStreamAsync(ct).ConfigureAwait(false); try { - await using var decompressor = ContentEncoding.CreateDecompressor(source, _encoding); + await using var decompressor = ContentEncoding.CreateDecompressor(source, encoding); await decompressor.CopyToAsync(stream, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or Protocol.HttpProtocolException) diff --git a/src/TurboHTTP/Internal/OptionsFactory.cs b/src/TurboHTTP/Internal/OptionsFactory.cs index 188f87dca..70c999ec7 100644 --- a/src/TurboHTTP/Internal/OptionsFactory.cs +++ b/src/TurboHTTP/Internal/OptionsFactory.cs @@ -45,7 +45,8 @@ internal static TransportOptions Build(RequestEndpoint endpoint, TurboClientOpti ConnectTimeout = clientOptions.ConnectTimeout, SocketSendBufferSize = clientOptions.SocketSendBufferSize, SocketReceiveBufferSize = clientOptions.SocketReceiveBufferSize, - AllowConnectionMigration = clientOptions.Http3.AllowConnectionMigration, + ReceiveBufferHint = clientOptions.ReceiveBufferHint, + MinimumSegmentSize = clientOptions.MinimumSegmentSize, IdleTimeout = clientOptions.Http3.IdleTimeout, MaxConnectionsPerHost = clientOptions.Http3.MaxConnectionsPerServer, MaxBidirectionalStreams = clientOptions.Http3.MaxConcurrentStreams, @@ -69,10 +70,12 @@ internal static TransportOptions Build(RequestEndpoint endpoint, TurboClientOpti ConnectTimeout = clientOptions.ConnectTimeout, SocketSendBufferSize = clientOptions.SocketSendBufferSize, SocketReceiveBufferSize = clientOptions.SocketReceiveBufferSize, + ReceiveBufferHint = clientOptions.ReceiveBufferHint, + MinimumSegmentSize = clientOptions.MinimumSegmentSize, UseProxy = clientOptions.UseProxy, Proxy = clientOptions.Proxy, DefaultProxyCredentials = clientOptions.DefaultProxyCredentials, - ApplicationProtocols = alpn, + ApplicationProtocols = alpn }; } @@ -84,9 +87,11 @@ internal static TransportOptions Build(RequestEndpoint endpoint, TurboClientOpti ConnectTimeout = clientOptions.ConnectTimeout, SocketSendBufferSize = clientOptions.SocketSendBufferSize, SocketReceiveBufferSize = clientOptions.SocketReceiveBufferSize, + ReceiveBufferHint = clientOptions.ReceiveBufferHint, + MinimumSegmentSize = clientOptions.MinimumSegmentSize, UseProxy = clientOptions.UseProxy, Proxy = clientOptions.Proxy, - DefaultProxyCredentials = clientOptions.DefaultProxyCredentials, + DefaultProxyCredentials = clientOptions.DefaultProxyCredentials }; } } \ No newline at end of file diff --git a/src/TurboHTTP/Internal/RecyclableStreams.cs b/src/TurboHTTP/Internal/RecyclableStreams.cs index 2d5114382..92757b97e 100644 --- a/src/TurboHTTP/Internal/RecyclableStreams.cs +++ b/src/TurboHTTP/Internal/RecyclableStreams.cs @@ -10,5 +10,12 @@ namespace TurboHTTP.Internal; /// internal static class RecyclableStreams { - internal static readonly RecyclableMemoryStreamManager Manager = new(); + internal static readonly RecyclableMemoryStreamManager Manager = new(new RecyclableMemoryStreamManager.Options + { + BlockSize = 128 * 1024, + LargeBufferMultiple = 1024 * 1024, + MaximumBufferSize = 8 * 1024 * 1024, + MaximumSmallPoolFreeBytes = 16 * 1024 * 1024, + MaximumLargePoolFreeBytes = 32 * 1024 * 1024 + }); } diff --git a/src/TurboHTTP/Protocol/Body/BodyDecoderOptions.cs b/src/TurboHTTP/Protocol/Body/BodyDecoderOptions.cs new file mode 100644 index 000000000..aa76b3dea --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/BodyDecoderOptions.cs @@ -0,0 +1,9 @@ +namespace TurboHTTP.Protocol.Body; + +internal sealed record BodyDecoderOptions +{ + public required long StreamingThreshold { get; init; } + public required long MaxBufferedBodySize { get; init; } + public required long? MaxStreamedBodySize { get; init; } + public required int MaxChunkExtensionLength { get; init; } +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Body/BodyDecoderOptionsExtensions.cs b/src/TurboHTTP/Protocol/Body/BodyDecoderOptionsExtensions.cs new file mode 100644 index 000000000..a73405d00 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/BodyDecoderOptionsExtensions.cs @@ -0,0 +1,39 @@ +using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Protocol.Syntax.Http11.Options; + +namespace TurboHTTP.Protocol.Body; + +internal static class BodyDecoderOptionsExtensions +{ + public static BodyDecoderOptions ToBodyDecoderOptions(this Http10ClientDecoderOptions o) => new() + { + StreamingThreshold = o.StreamingThreshold, + MaxBufferedBodySize = o.MaxBufferedBodySize, + MaxStreamedBodySize = o.MaxStreamedBodySize, + MaxChunkExtensionLength = int.MaxValue + }; + + public static BodyDecoderOptions ToBodyDecoderOptions(this Http11ClientDecoderOptions o) => new() + { + StreamingThreshold = o.StreamingThreshold, + MaxBufferedBodySize = o.MaxBufferedBodySize, + MaxStreamedBodySize = o.MaxStreamedBodySize, + MaxChunkExtensionLength = o.MaxChunkExtensionLength + }; + + public static BodyDecoderOptions ToBodyDecoderOptions(this Http10ServerDecoderOptions o) => new() + { + StreamingThreshold = o.StreamingThreshold, + MaxBufferedBodySize = o.MaxBufferedBodySize, + MaxStreamedBodySize = o.MaxStreamedBodySize, + MaxChunkExtensionLength = int.MaxValue + }; + + public static BodyDecoderOptions ToBodyDecoderOptions(this Http11ServerDecoderOptions o) => new() + { + StreamingThreshold = o.StreamingThreshold, + MaxBufferedBodySize = o.MaxBufferedBodySize, + MaxStreamedBodySize = o.MaxStreamedBodySize, + MaxChunkExtensionLength = o.MaxChunkExtensionLength + }; +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Body/BodyEncoderOptions.cs b/src/TurboHTTP/Protocol/Body/BodyEncoderOptions.cs new file mode 100644 index 000000000..9ba536013 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/BodyEncoderOptions.cs @@ -0,0 +1,6 @@ +namespace TurboHTTP.Protocol.Body; + +internal sealed record BodyEncoderOptions +{ + public required int ChunkSize { get; init; } +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Body/BodyReadResult.cs b/src/TurboHTTP/Protocol/Body/BodyReadResult.cs new file mode 100644 index 000000000..23a3ab784 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/BodyReadResult.cs @@ -0,0 +1,7 @@ +namespace TurboHTTP.Protocol.Body; + +internal readonly struct BodyReadResult(ReadOnlyMemory memory, bool isCompleted) +{ + public ReadOnlyMemory Memory { get; } = memory; + public bool IsCompleted { get; } = isCompleted; +} diff --git a/src/TurboHTTP/Protocol/Body/BodyReaderFactory.cs b/src/TurboHTTP/Protocol/Body/BodyReaderFactory.cs new file mode 100644 index 000000000..ad08c65d4 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/BodyReaderFactory.cs @@ -0,0 +1,53 @@ +using TurboHTTP.Protocol.Semantics; + +namespace TurboHTTP.Protocol.Body; + +internal static class BodyReaderFactory +{ + public static (IBodyReader? Reader, IFramingDecoder? Decoder) Create(BodyClassification classification, BodyDecoderOptions options) + { + switch (classification.Framing) + { + case BodyFraming.None: + return (null, null); + + case BodyFraming.Length: + { + var n = classification.ContentLength ?? 0; + if (n <= options.StreamingThreshold && n <= options.MaxBufferedBodySize) + { + var reader = new BufferedBodyReader(); + reader.Reset((int)n); + return (reader, null); + } + + var queued = new QueuedBodyReader(capacity: 4); + queued.Reset(); + var decoder = new ContentLengthFramingDecoder(); + decoder.Reset(n); + return (queued, decoder); + } + + case BodyFraming.Chunked: + { + var queued = new QueuedBodyReader(capacity: 4); + queued.Reset(); + var decoder = new ChunkedFramingDecoder(); + decoder.Reset(options.MaxStreamedBodySize ?? long.MaxValue, options.MaxChunkExtensionLength); + return (queued, decoder); + } + + case BodyFraming.Close: + { + var queued = new QueuedBodyReader(capacity: 4); + queued.Reset(); + var decoder = new CloseDelimitedFramingDecoder(); + decoder.Reset(options.MaxStreamedBodySize ?? long.MaxValue); + return (queued, decoder); + } + + default: + throw new ArgumentOutOfRangeException(nameof(classification)); + } + } +} diff --git a/src/TurboHTTP/Protocol/Body/BufferedBodyReader.cs b/src/TurboHTTP/Protocol/Body/BufferedBodyReader.cs new file mode 100644 index 000000000..3d6d56ae0 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/BufferedBodyReader.cs @@ -0,0 +1,171 @@ +using System.Buffers; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class BufferedBodyReader : IBufferedBodyReader +{ + private IMemoryOwner? _owner; + private int _expected; + private int _received; + private bool _openEnded; + + public bool IsBuffered => true; + public bool IsCompleted { get; private set; } + public bool IsOpenEnded => _openEnded; + + public void Reset(int contentLength) + { + ArgumentOutOfRangeException.ThrowIfNegative(contentLength); + _owner?.Dispose(); + _expected = contentLength; + _openEnded = false; + _received = 0; + IsCompleted = contentLength == 0; + _owner = contentLength > 0 + ? MemoryPool.Shared.Rent(contentLength) + : null; + } + + public void ResetOpenEnded() + { + _owner?.Dispose(); + _expected = 0; + _openEnded = true; + _received = 0; + IsCompleted = false; + _owner = MemoryPool.Shared.Rent(4 * 1024); + } + + public void MarkComplete() + { + IsCompleted = true; + } + + public int Feed(ReadOnlySpan data) + { + if (_openEnded) + { + if (data.IsEmpty) + { + return 0; + } + + EnsureCapacity(_received + data.Length); + data.CopyTo(_owner!.Memory.Span[_received..]); + _received += data.Length; + return data.Length; + } + + var take = Math.Min(_expected - _received, data.Length); + if (take > 0) + { + data[..take].CopyTo(_owner!.Memory.Span[_received..]); + _received += take; + } + + IsCompleted = _received == _expected; + return take; + } + + private void EnsureCapacity(int needed) + { + if (_owner is not null && _owner.Memory.Length >= needed) + { + return; + } + + var newSize = Math.Max(needed, (_owner?.Memory.Length ?? 4 * 1024) * 2); + var next = MemoryPool.Shared.Rent(newSize); + if (_owner is not null && _received > 0) + { + _owner.Memory[.._received].CopyTo(next.Memory); + } + + _owner?.Dispose(); + _owner = next; + } + + public ReadOnlyMemory GetBody() + => _owner?.Memory[.._received] ?? ReadOnlyMemory.Empty; + + public Stream AsStream() + => _owner is not null + ? new PooledMemoryStream(_owner, _received) + : Stream.Null; + + public void Dispose() + { + _owner?.Dispose(); + _owner = null; + } + + private sealed class PooledMemoryStream(IMemoryOwner owner, int length) : Stream + { + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length => length; + + public override long Position + { + get => _position; + set => _position = (int)value; + } + + public override int Read(byte[] buffer, int offset, int count) + { + var available = length - _position; + if (available <= 0) + { + return 0; + } + + var toCopy = Math.Min(count, available); + owner.Memory.Span.Slice(_position, toCopy).CopyTo(buffer.AsSpan(offset, toCopy)); + _position += toCopy; + return toCopy; + } + + public override int Read(Span buffer) + { + var available = length - _position; + if (available <= 0) + { + return 0; + } + + var toCopy = Math.Min(buffer.Length, available); + owner.Memory.Span.Slice(_position, toCopy).CopyTo(buffer[..toCopy]); + _position += toCopy; + return toCopy; + } + + public override long Seek(long offset, SeekOrigin origin) + { + _position = origin switch + { + SeekOrigin.Begin => (int)offset, + SeekOrigin.Current => _position + (int)offset, + SeekOrigin.End => length + (int)offset, + _ => _position + }; + return _position; + } + + public override void Flush() { } + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + owner.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/src/TurboHTTP/Protocol/Body/BufferedBodyWriter.cs b/src/TurboHTTP/Protocol/Body/BufferedBodyWriter.cs new file mode 100644 index 000000000..48e6a3343 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/BufferedBodyWriter.cs @@ -0,0 +1,67 @@ +using System.Buffers; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class BufferedBodyWriter : IBodyWriter +{ + private IMemoryOwner? _owner; + private int _written; + private Action, int>? _onComplete; + + public void Reset(Action, int> onComplete) + { + _owner?.Dispose(); + _owner = MemoryPool.Shared.Rent(4 * 1024); + _written = 0; + _onComplete = onComplete; + } + + public Memory GetMemory(int sizeHint = 0) + { + var needed = _written + Math.Max(sizeHint, 4 * 1024); + EnsureCapacity(needed); + return _owner!.Memory[_written..]; + } + + public void Advance(int bytes) + { + _written += bytes; + } + + public ValueTask FlushAsync(CancellationToken ct = default) + => ValueTask.FromResult(new FlushResult(false)); + + public ValueTask CompleteAsync(CancellationToken ct = default) + { + var owner = _owner!; + var written = _written; + _owner = null; + _written = 0; + _onComplete!(owner, written); + return default; + } + + private void EnsureCapacity(int needed) + { + if (_owner is not null && _owner.Memory.Length >= needed) + { + return; + } + + var newSize = Math.Max(needed, (_owner?.Memory.Length ?? 4 * 1024) * 2); + var next = MemoryPool.Shared.Rent(newSize); + if (_owner is not null && _written > 0) + { + _owner.Memory[.._written].CopyTo(next.Memory); + } + + _owner?.Dispose(); + _owner = next; + } + + public void Dispose() + { + _owner?.Dispose(); + _owner = null; + } +} diff --git a/src/TurboHTTP/Protocol/Body/ChunkedFramingDecoder.cs b/src/TurboHTTP/Protocol/Body/ChunkedFramingDecoder.cs new file mode 100644 index 000000000..a131816e5 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/ChunkedFramingDecoder.cs @@ -0,0 +1,246 @@ +using System.Globalization; +using System.Text; +using TurboHTTP.Protocol.LineBased; +using TurboHTTP.Protocol.Semantics; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class ChunkedFramingDecoder : IFramingDecoder +{ + private enum Phase + { + ChunkSize, + ChunkData, + ChunkDataCrlf, + Trailer, + Complete + } + + private const int MaxControlLineLength = 64 * 1024; + private const int MaxTrailerSectionBytes = 32 * 1024; + + private Phase _phase; + private int _currentChunkRemaining; + private byte[] _stash = []; + private int _stashLen; + private long _totalBodyBytes; + private long _maxBodySize; + private int _maxChunkExtensionLength; + private List<(string Name, string Value)>? _trailers; + private int _trailerSectionBytes; + + public bool SupportsZeroCopy => false; + public bool IsComplete => _phase == Phase.Complete; + + public IReadOnlyList<(string Name, string Value)> Trailers + => _trailers ?? (IReadOnlyList<(string Name, string Value)>)[]; + + public void Reset(long maxBodySize, int maxChunkExtensionLength) + { + _phase = Phase.ChunkSize; + _currentChunkRemaining = 0; + _stashLen = 0; + _totalBodyBytes = 0; + _maxBodySize = maxBodySize; + _maxChunkExtensionLength = maxChunkExtensionLength; + _trailers?.Clear(); + _trailerSectionBytes = 0; + } + + public FramingDecodeResult Decode(ReadOnlySpan raw, out int rawConsumed) + { + rawConsumed = 0; + if (_phase == Phase.Complete) + { + return new FramingDecodeResult(default, true); + } + + ReadOnlySpan work; + var stashOffset = _stashLen; + if (_stashLen > 0) + { + EnsureStash(_stashLen + raw.Length); + raw.CopyTo(_stash.AsSpan(_stashLen)); + work = _stash.AsSpan(0, _stashLen + raw.Length); + } + else + { + work = raw; + } + + var pos = 0; + ReadOnlySpan bodyOutput = default; + var hasBody = false; + var incompleteLine = false; + + while (pos < work.Length) + { + if (hasBody && _phase == Phase.ChunkData) + { + break; + } + + switch (_phase) + { + case Phase.ChunkSize: + { + var crlf = BufferSearch.FindCrlf(work, pos); + if (crlf < 0) + { + incompleteLine = true; + goto stash; + } + + var line = work[pos..crlf]; + var semi = line.IndexOf((byte)';'); + if (semi >= 0 && line.Length - semi > _maxChunkExtensionLength) + { + throw new HttpProtocolException("Chunk extension exceeds configured maximum length."); + } + + var sizeSpan = semi < 0 ? line : line[..semi]; + if (!ulong.TryParse(Encoding.ASCII.GetString(sizeSpan), + NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var chunkSize) + || chunkSize > int.MaxValue) + { + throw new HttpProtocolException("Invalid chunk size."); + } + + _currentChunkRemaining = (int)chunkSize; + pos = crlf + 2; + _phase = _currentChunkRemaining == 0 ? Phase.Trailer : Phase.ChunkData; + break; + } + case Phase.ChunkData: + { + var avail = work.Length - pos; + var take = Math.Min(_currentChunkRemaining, avail); + if (take > 0) + { + _totalBodyBytes += take; + if (_totalBodyBytes > _maxBodySize) + { + throw new HttpProtocolException( + $"Request body size {_totalBodyBytes} exceeds limit {_maxBodySize}."); + } + + bodyOutput = work.Slice(pos, take); + hasBody = true; + _currentChunkRemaining -= take; + pos += take; + + if (_currentChunkRemaining == 0) + { + _phase = Phase.ChunkDataCrlf; + } + + break; + } + + incompleteLine = true; + goto stash; + } + case Phase.ChunkDataCrlf: + { + if (work.Length - pos < 2) + { + incompleteLine = true; + goto stash; + } + + if (work[pos] != (byte)'\r' || work[pos + 1] != (byte)'\n') + { + throw new HttpProtocolException("Missing CRLF after chunk-data."); + } + + pos += 2; + _phase = Phase.ChunkSize; + break; + } + case Phase.Trailer: + { + var crlf = BufferSearch.FindCrlf(work, pos); + if (crlf < 0) + { + incompleteLine = true; + goto stash; + } + + if (crlf == pos) + { + pos += 2; + _phase = Phase.Complete; + _stashLen = 0; + rawConsumed = pos - stashOffset; + if (rawConsumed < 0) rawConsumed = 0; + return new FramingDecodeResult(bodyOutput, true); + } + + var trailerLine = work[pos..crlf]; + _trailerSectionBytes += trailerLine.Length + 2; + if (_trailerSectionBytes > MaxTrailerSectionBytes) + { + throw new HttpProtocolException("Trailer section exceeds maximum size."); + } + + if (HeaderFieldParser.TryParse(trailerLine, out var fieldName, out var fieldValue) + && TrailerFieldValidator.IsAllowedInTrailer(fieldName)) + { + _trailers ??= []; + _trailers.Add((fieldName, fieldValue)); + } + + pos = crlf + 2; + break; + } + } + } + + stash: + var remaining = work.Length - pos; + if (incompleteLine && _phase is Phase.ChunkSize or Phase.Trailer + && remaining > Math.Max(MaxControlLineLength, _maxChunkExtensionLength)) + { + throw new HttpProtocolException("Chunk control line exceeds maximum length."); + } + + if (incompleteLine && remaining > 0) + { + EnsureStash(remaining); + work[pos..].CopyTo(_stash); + _stashLen = remaining; + } + else + { + _stashLen = 0; + } + + rawConsumed = Math.Max(0, pos - stashOffset); + + return new FramingDecodeResult(bodyOutput, false); + } + + private void EnsureStash(int needed) + { + if (_stash.Length < needed) + { + Array.Resize(ref _stash, Math.Max(needed, _stash.Length * 2 + 16)); + } + } + + public bool OnEof() + { + return _phase == Phase.Complete; + } + + public int Drain(ReadOnlySpan raw) + { + if (_phase == Phase.Complete) + { + return 0; + } + + Decode(raw, out var consumed); + return consumed; + } +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Body/ChunkedFramingEncoder.cs b/src/TurboHTTP/Protocol/Body/ChunkedFramingEncoder.cs new file mode 100644 index 000000000..83df8abb8 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/ChunkedFramingEncoder.cs @@ -0,0 +1,50 @@ +using System.Buffers; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class ChunkedFramingEncoder(int maxChunkSize) : IFramingEncoder +{ + public int Headroom { get; } = HexDigitCount(maxChunkSize) + 2; + public int Trailer => 2; + + public ReadOnlyMemory Frame(IMemoryOwner buffer, int headroom, int dataLength) + { + var actualHexLen = HexDigitCount(dataLength); + var chunkStart = headroom - actualHexLen - 2; + + var headerWriter = SpanWriter.Create(buffer.Memory.Span[chunkStart..]); + headerWriter.WriteHex(dataLength); + headerWriter.WriteCrlf(); + + var trailerWriter = SpanWriter.Create(buffer.Memory.Span[(headroom + dataLength)..]); + trailerWriter.WriteCrlf(); + + var chunkLen = actualHexLen + 2 + dataLength + 2; + return buffer.Memory.Slice(chunkStart, chunkLen); + } + + public OwnedMemory GetTerminator() + { + var owner = MemoryPool.Shared.Rent(5); + var writer = SpanWriter.Create(owner.Memory.Span); + writer.WriteBytes(WellKnownHeaders.ZeroValue); + writer.WriteCrlf(); + writer.WriteCrlf(); + return new OwnedMemory(owner, owner.Memory[..writer.BytesWritten]); + } + + private static int HexDigitCount(int value) + { + return value switch + { + <= 0xF => 1, + <= 0xFF => 2, + <= 0xFFF => 3, + <= 0xFFFF => 4, + <= 0xFFFFF => 5, + <= 0xFFFFFF => 6, + <= 0xFFFFFFF => 7, + _ => 8 + }; + } +} diff --git a/src/TurboHTTP/Protocol/Body/CloseDelimitedFramingDecoder.cs b/src/TurboHTTP/Protocol/Body/CloseDelimitedFramingDecoder.cs new file mode 100644 index 000000000..7750ddb7a --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/CloseDelimitedFramingDecoder.cs @@ -0,0 +1,38 @@ +namespace TurboHTTP.Protocol.Body; + +internal sealed class CloseDelimitedFramingDecoder : IFramingDecoder +{ + private long _totalBytes; + private long _maxBodySize; + + public bool SupportsZeroCopy => true; + public bool IsComplete { get; private set; } + public IReadOnlyList<(string Name, string Value)> Trailers => []; + + public void Reset(long maxBodySize) + { + _totalBytes = 0; + _maxBodySize = maxBodySize; + IsComplete = false; + } + + public FramingDecodeResult Decode(ReadOnlySpan raw, out int rawConsumed) + { + _totalBytes += raw.Length; + if (_totalBytes > _maxBodySize) + { + throw new HttpProtocolException($"Request body size {_totalBytes} exceeds limit {_maxBodySize}."); + } + + rawConsumed = raw.Length; + return new FramingDecodeResult(raw, false); + } + + public bool OnEof() + { + IsComplete = true; + return true; + } + + public int Drain(ReadOnlySpan raw) => raw.Length; +} diff --git a/src/TurboHTTP/Protocol/Body/ConnectionBodyPool.cs b/src/TurboHTTP/Protocol/Body/ConnectionBodyPool.cs new file mode 100644 index 000000000..d7127e319 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/ConnectionBodyPool.cs @@ -0,0 +1,99 @@ +using System.Buffers; +using System.Net; +using TurboHTTP.Protocol.Semantics; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class ConnectionBodyPool : IDisposable +{ + private readonly BufferedBodyReader _bufferedReader = new(); + private readonly QueuedBodyReader _queuedReader = new(capacity: 4); + private readonly ContentLengthFramingDecoder _contentLengthDecoder = new(); + private readonly ChunkedFramingDecoder _chunkedDecoder = new(); + private readonly CloseDelimitedFramingDecoder _closeDelimitedDecoder = new(); + private readonly BufferedBodyWriter _bufferedWriter = new(); + private readonly StreamingBodyWriter _streamingWriter = new(); + + public (IBodyReader? Reader, IFramingDecoder? Decoder) RentReader( + BodyClassification classification, BodyDecoderOptions options) + { + switch (classification.Framing) + { + case BodyFraming.None: + return (null, null); + + case BodyFraming.Length: + { + var n = classification.ContentLength ?? 0; + if (n <= options.StreamingThreshold && n <= options.MaxBufferedBodySize) + { + _bufferedReader.Reset((int)n); + return (_bufferedReader, null); + } + + _queuedReader.Reset(); + _contentLengthDecoder.Reset(n); + return (_queuedReader, _contentLengthDecoder); + } + + case BodyFraming.Chunked: + { + _queuedReader.Reset(); + _chunkedDecoder.Reset( + options.MaxStreamedBodySize ?? long.MaxValue, + options.MaxChunkExtensionLength); + return (_queuedReader, _chunkedDecoder); + } + + case BodyFraming.Close: + { + _queuedReader.Reset(); + _closeDelimitedDecoder.Reset(options.MaxStreamedBodySize ?? long.MaxValue); + return (_queuedReader, _closeDelimitedDecoder); + } + + default: + throw new ArgumentOutOfRangeException(nameof(classification)); + } + } + + public void ReturnReader() + { + } + + public (IBodyWriter? Writer, IFramingEncoder? Encoder) RentWriter( + bool hasBody, long? contentLength, Version httpVersion, BodyEncoderOptions options, + Func, ReadOnlyMemory, ValueTask> send, + Action, int>? onBufferedComplete = null) + { + if (!hasBody) + { + return (null, null); + } + + if (contentLength is not null) + { + var encoder = new PassthroughFramingEncoder(); + _streamingWriter.Reset(encoder, send); + return (_streamingWriter, encoder); + } + + if (httpVersion == HttpVersion.Version10) + { + _bufferedWriter.Reset(onBufferedComplete!); + return (_bufferedWriter, null); + } + + var framingEncoder = new ChunkedFramingEncoder(options.ChunkSize); + _streamingWriter.Reset(framingEncoder, send); + return (_streamingWriter, framingEncoder); + } + + public void Dispose() + { + _bufferedReader.Dispose(); + _queuedReader.Dispose(); + _bufferedWriter.Dispose(); + _streamingWriter.Dispose(); + } +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Body/ContentLengthFramingDecoder.cs b/src/TurboHTTP/Protocol/Body/ContentLengthFramingDecoder.cs new file mode 100644 index 000000000..145976353 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/ContentLengthFramingDecoder.cs @@ -0,0 +1,33 @@ +namespace TurboHTTP.Protocol.Body; + +internal sealed class ContentLengthFramingDecoder : IFramingDecoder +{ + private long _remaining; + + public bool SupportsZeroCopy => true; + public bool IsComplete => _remaining == 0; + public IReadOnlyList<(string Name, string Value)> Trailers => []; + + public void Reset(long contentLength) + { + ArgumentOutOfRangeException.ThrowIfNegative(contentLength); + _remaining = contentLength; + } + + public FramingDecodeResult Decode(ReadOnlySpan raw, out int rawConsumed) + { + var take = (int)Math.Min(_remaining, raw.Length); + rawConsumed = take; + _remaining -= take; + return new FramingDecodeResult(raw[..take], _remaining == 0); + } + + public bool OnEof() => _remaining == 0; + + public int Drain(ReadOnlySpan raw) + { + var take = (int)Math.Min(_remaining, raw.Length); + _remaining -= take; + return take; + } +} diff --git a/src/TurboHTTP/Protocol/Body/FlushResult.cs b/src/TurboHTTP/Protocol/Body/FlushResult.cs new file mode 100644 index 000000000..4dac7419c --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/FlushResult.cs @@ -0,0 +1,6 @@ +namespace TurboHTTP.Protocol.Body; + +internal readonly struct FlushResult(bool isCompleted) +{ + public bool IsCompleted { get; } = isCompleted; +} diff --git a/src/TurboHTTP/Protocol/Body/IBodyReader.cs b/src/TurboHTTP/Protocol/Body/IBodyReader.cs new file mode 100644 index 000000000..e72f16421 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/IBodyReader.cs @@ -0,0 +1,8 @@ +namespace TurboHTTP.Protocol.Body; + +internal interface IBodyReader : IDisposable +{ + bool IsBuffered { get; } + bool IsCompleted { get; } + Stream AsStream(); +} diff --git a/src/TurboHTTP/Protocol/Body/IBodyWriter.cs b/src/TurboHTTP/Protocol/Body/IBodyWriter.cs new file mode 100644 index 000000000..eae913159 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/IBodyWriter.cs @@ -0,0 +1,9 @@ +namespace TurboHTTP.Protocol.Body; + +internal interface IBodyWriter : IDisposable +{ + Memory GetMemory(int sizeHint = 0); + void Advance(int bytes); + ValueTask FlushAsync(CancellationToken ct = default); + ValueTask CompleteAsync(CancellationToken ct = default); +} diff --git a/src/TurboHTTP/Protocol/Body/IBufferedBodyReader.cs b/src/TurboHTTP/Protocol/Body/IBufferedBodyReader.cs new file mode 100644 index 000000000..9620df038 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/IBufferedBodyReader.cs @@ -0,0 +1,8 @@ +namespace TurboHTTP.Protocol.Body; + +internal interface IBufferedBodyReader : IBodyReader +{ + int Feed(ReadOnlySpan data); + void MarkComplete(); + ReadOnlyMemory GetBody(); +} diff --git a/src/TurboHTTP/Protocol/Body/IFramingDecoder.cs b/src/TurboHTTP/Protocol/Body/IFramingDecoder.cs new file mode 100644 index 000000000..0d6beedd4 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/IFramingDecoder.cs @@ -0,0 +1,17 @@ +namespace TurboHTTP.Protocol.Body; + +internal readonly ref struct FramingDecodeResult(ReadOnlySpan body, bool endOfBody) +{ + public ReadOnlySpan Body { get; } = body; + public bool EndOfBody { get; } = endOfBody; +} + +internal interface IFramingDecoder +{ + bool SupportsZeroCopy { get; } + bool IsComplete { get; } + FramingDecodeResult Decode(ReadOnlySpan raw, out int rawConsumed); + bool OnEof(); + int Drain(ReadOnlySpan raw); + IReadOnlyList<(string Name, string Value)> Trailers { get; } +} diff --git a/src/TurboHTTP/Protocol/Body/IFramingEncoder.cs b/src/TurboHTTP/Protocol/Body/IFramingEncoder.cs new file mode 100644 index 000000000..d3313284b --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/IFramingEncoder.cs @@ -0,0 +1,18 @@ +using System.Buffers; + +namespace TurboHTTP.Protocol.Body; + +internal readonly struct OwnedMemory(IMemoryOwner owner, ReadOnlyMemory memory) +{ + public IMemoryOwner Owner { get; } = owner; + public ReadOnlyMemory Memory { get; } = memory; + public bool IsEmpty => Memory.IsEmpty; +} + +internal interface IFramingEncoder +{ + int Headroom { get; } + int Trailer { get; } + ReadOnlyMemory Frame(IMemoryOwner buffer, int headroom, int dataLength); + OwnedMemory GetTerminator(); +} diff --git a/src/TurboHTTP/Protocol/Body/IStreamingBodyReader.cs b/src/TurboHTTP/Protocol/Body/IStreamingBodyReader.cs new file mode 100644 index 000000000..43e232487 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/IStreamingBodyReader.cs @@ -0,0 +1,12 @@ +namespace TurboHTTP.Protocol.Body; + +internal interface IStreamingBodyReader : IBodyReader +{ + bool TryEnqueue(ReadOnlySpan data); + void Complete(); + void Fault(Exception ex); + ValueTask ReadAsync(CancellationToken ct = default); + void AdvanceTo(); + bool IsFull { get; } + event Action? SlotFreed; +} diff --git a/src/TurboHTTP/Protocol/Body/OwnedChunk.cs b/src/TurboHTTP/Protocol/Body/OwnedChunk.cs new file mode 100644 index 000000000..1b79820bf --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/OwnedChunk.cs @@ -0,0 +1,8 @@ +namespace TurboHTTP.Protocol.Body; + +internal readonly struct OwnedChunk(byte[]? rental, int length) +{ + public byte[]? Rental { get; } = rental; + public int Length { get; } = length; + public ReadOnlyMemory Memory => Rental?.AsMemory(0, Length) ?? default; +} diff --git a/src/TurboHTTP/Protocol/Body/PassthroughFramingEncoder.cs b/src/TurboHTTP/Protocol/Body/PassthroughFramingEncoder.cs new file mode 100644 index 000000000..8706080eb --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/PassthroughFramingEncoder.cs @@ -0,0 +1,14 @@ +using System.Buffers; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class PassthroughFramingEncoder : IFramingEncoder +{ + public int Headroom => 0; + public int Trailer => 0; + + public ReadOnlyMemory Frame(IMemoryOwner buffer, int headroom, int dataLength) + => buffer.Memory[..dataLength]; + + public OwnedMemory GetTerminator() => default; +} diff --git a/src/TurboHTTP/Protocol/Body/QueuedBodyReader.cs b/src/TurboHTTP/Protocol/Body/QueuedBodyReader.cs new file mode 100644 index 000000000..ecc91691f --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/QueuedBodyReader.cs @@ -0,0 +1,300 @@ +using System.Buffers; +using System.Threading.Tasks.Sources; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class QueuedBodyReader : IStreamingBodyReader, IValueTaskSource +{ + // This reader is a true cross-thread boundary: the connection-stage (actor) thread + // produces via TryEnqueue/Complete/Fault while the application thread consumes via + // ReadAsync/AdvanceTo. All mutable state is guarded by _sync; completions are + // delivered outside the lock and continuations run asynchronously so consumer code + // never executes on the producing stage thread. + private readonly object _sync = new(); + + private OwnedChunk[] _slots; + private readonly int _backpressureThreshold; + private int _head; + private int _tail; + private int _count; + private OwnedChunk _current; + private ManualResetValueTaskSourceCore _core; + private bool _readPending; + private bool _completed; + private Exception? _fault; + + private readonly int _initialSlotCount; + + public QueuedBodyReader(int capacity) + { + _backpressureThreshold = capacity; + _initialSlotCount = capacity * 2; + _slots = new OwnedChunk[_initialSlotCount]; + _core.RunContinuationsAsynchronously = true; + } + + public bool IsBuffered => false; + + public bool IsCompleted + { + get + { + lock (_sync) + { + return _completed && _count == 0 && _current.Rental is null; + } + } + } + + public bool IsFull + { + get + { + lock (_sync) + { + return _count >= _backpressureThreshold; + } + } + } + + public event Action? SlotFreed; + + public bool TryEnqueue(ReadOnlySpan data) + { + var rental = ArrayPool.Shared.Rent(data.Length); + data.CopyTo(rental); + var chunk = new OwnedChunk(rental, data.Length); + + bool deliverDirectly; + bool belowThreshold; + + lock (_sync) + { + if (_readPending) + { + // _readPending is only set while the queue is empty, so direct delivery + // cannot overtake queued chunks. + _readPending = false; + _current = chunk; + deliverDirectly = true; + } + else + { + if (_count == _slots.Length) + { + Grow(); + } + + _slots[_tail] = chunk; + _tail = (_tail + 1) % _slots.Length; + _count++; + deliverDirectly = false; + } + + belowThreshold = _count < _backpressureThreshold; + } + + if (deliverDirectly) + { + _core.SetResult(new BodyReadResult(chunk.Memory, isCompleted: false)); + } + + return belowThreshold; + } + + public void Complete() + { + bool deliver; + + lock (_sync) + { + _completed = true; + deliver = _readPending && _count == 0; + if (deliver) + { + _readPending = false; + } + } + + if (deliver) + { + _core.SetResult(new BodyReadResult(default, isCompleted: true)); + } + } + + public void Fault(Exception ex) + { + bool deliver; + + lock (_sync) + { + _fault = ex; + deliver = _readPending; + if (deliver) + { + _readPending = false; + } + } + + if (deliver) + { + _core.SetException(ex); + } + } + + public ValueTask ReadAsync(CancellationToken ct = default) + { + lock (_sync) + { + if (_count > 0) + { + _current = _slots[_head]; + _slots[_head] = default; + _head = (_head + 1) % _slots.Length; + _count--; + return new ValueTask(new BodyReadResult(_current.Memory, isCompleted: false)); + } + + if (_completed) + { + return new ValueTask(new BodyReadResult(default, isCompleted: true)); + } + + if (_fault is not null) + { + return ValueTask.FromException(_fault); + } + + if (ct.IsCancellationRequested) + { + return ValueTask.FromCanceled(ct); + } + + // Reset before publishing _readPending: once _readPending is visible, a + // producer may complete the core at any moment. + _core.Reset(); + _readPending = true; + } + + if (ct.CanBeCanceled) + { + ct.UnsafeRegister(static (state, token) => + { + var self = (QueuedBodyReader)state!; + bool deliver; + + lock (self._sync) + { + deliver = self._readPending; + if (deliver) + { + self._readPending = false; + } + } + + if (deliver) + { + self._core.SetException(new OperationCanceledException(token)); + } + }, this); + } + + return new ValueTask(this, _core.Version); + } + + public void AdvanceTo() + { + lock (_sync) + { + if (_current.Rental is not null) + { + ArrayPool.Shared.Return(_current.Rental); + } + + _current = default; + } + + SlotFreed?.Invoke(); + } + + private void Grow() + { + var newLength = _slots.Length * 2; + var newSlots = new OwnedChunk[newLength]; + + for (var i = 0; i < _count; i++) + { + newSlots[i] = _slots[(_head + i) % _slots.Length]; + } + + _slots = newSlots; + _head = 0; + _tail = _count; + } + + public void Reset() + { + bool deliver; + + lock (_sync) + { + deliver = _readPending; + _readPending = false; + + while (_count > 0) + { + var chunk = _slots[_head]; + _slots[_head] = default; + _head = (_head + 1) % _slots.Length; + _count--; + + if (chunk.Rental is not null) + { + ArrayPool.Shared.Return(chunk.Rental); + } + } + + if (_current.Rental is not null) + { + ArrayPool.Shared.Return(_current.Rental); + } + + _current = default; + _head = 0; + _tail = 0; + _count = 0; + _completed = false; + _fault = null; + + if (!deliver) + { + _core = default; + _core.RunContinuationsAsynchronously = true; + } + + if (_slots.Length != _initialSlotCount) + { + _slots = new OwnedChunk[_initialSlotCount]; + } + } + + if (deliver) + { + // A consumer is still awaiting: complete its pending read instead of + // resetting the core underneath it. + _core.SetResult(new BodyReadResult(default, isCompleted: true)); + } + } + + public Stream AsStream() => new QueuedBodyStream(this); + + public void Dispose() => Reset(); + + BodyReadResult IValueTaskSource.GetResult(short token) => _core.GetResult(token); + + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => _core.GetStatus(token); + + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, + ValueTaskSourceOnCompletedFlags flags) + => _core.OnCompleted(continuation, state, token, flags); +} diff --git a/src/TurboHTTP/Protocol/Body/QueuedBodyStream.cs b/src/TurboHTTP/Protocol/Body/QueuedBodyStream.cs new file mode 100644 index 000000000..eddeeca75 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/QueuedBodyStream.cs @@ -0,0 +1,108 @@ +namespace TurboHTTP.Protocol.Body; + +internal sealed class QueuedBodyStream(QueuedBodyReader reader) : Stream +{ + private ReadOnlyMemory _current; + private int _offset; + private bool _done; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + => Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) + { + if (_done) + { + return 0; + } + + if (_current.IsEmpty) + { + var result = ReadNextSegment(); + if (result is { IsCompleted: true, Memory.IsEmpty: true }) + { + _done = true; + return 0; + } + + _current = result.Memory; + _offset = 0; + } + + return CopyFromCurrent(buffer); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_done) + { + return 0; + } + + if (_current.IsEmpty) + { + var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + if (result is { IsCompleted: true, Memory.IsEmpty: true }) + { + _done = true; + return 0; + } + + _current = result.Memory; + _offset = 0; + } + + return CopyFromCurrent(buffer.Span); + } + + private int CopyFromCurrent(Span destination) + { + var available = _current.Length - _offset; + var toCopy = Math.Min(available, destination.Length); + _current.Span.Slice(_offset, toCopy).CopyTo(destination); + _offset += toCopy; + + if (_offset >= _current.Length) + { + _current = default; + _offset = 0; + reader.AdvanceTo(); + } + + return toCopy; + } + + private BodyReadResult ReadNextSegment() + { + var vt = reader.ReadAsync(CancellationToken.None); + if (!vt.IsCompleted) + { + throw new InvalidOperationException( + "QueuedBodyReader.ReadAsync not completed synchronously — use ReadAsync on the stream."); + } + + return vt.Result; + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/src/TurboHTTP/Protocol/Body/StreamBodyMessages.cs b/src/TurboHTTP/Protocol/Body/StreamBodyMessages.cs new file mode 100644 index 000000000..27f137d47 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/StreamBodyMessages.cs @@ -0,0 +1,11 @@ +using System.Buffers; + +namespace TurboHTTP.Protocol.Body; + +internal sealed record StreamBodyChunk( + IMemoryOwner Owner, + int Length, + int Offset = 0) +{ + public ReadOnlyMemory Data => Owner.Memory.Slice(Offset, Length); +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Body/StreamingBodyWriter.cs b/src/TurboHTTP/Protocol/Body/StreamingBodyWriter.cs new file mode 100644 index 000000000..9027019a3 --- /dev/null +++ b/src/TurboHTTP/Protocol/Body/StreamingBodyWriter.cs @@ -0,0 +1,62 @@ +using System.Buffers; + +namespace TurboHTTP.Protocol.Body; + +internal sealed class StreamingBodyWriter : IBodyWriter +{ + private IFramingEncoder? _framing; + private Func, ReadOnlyMemory, ValueTask>? _send; + private IMemoryOwner? _rental; + private int _written; + + public void Reset(IFramingEncoder framing, Func, ReadOnlyMemory, ValueTask> send) + { + _framing = framing; + _send = send; + _rental?.Dispose(); + _rental = null; + _written = 0; + } + + public Memory GetMemory(int sizeHint = 0) + { + var size = Math.Max(sizeHint, 4 * 1024); + var totalSize = _framing!.Headroom + size + _framing.Trailer; + _rental = MemoryPool.Shared.Rent(totalSize); + _written = 0; + return _rental.Memory.Slice(_framing.Headroom, size); + } + + public void Advance(int bytes) + { + _written += bytes; + } + + public ValueTask FlushAsync(CancellationToken ct = default) + { + var framed = _framing!.Frame(_rental!, _framing.Headroom, _written); + var owner = _rental!; + _rental = null; + _written = 0; + _send!(owner, framed); + return ValueTask.FromResult(new FlushResult(isCompleted: false)); + } + + public ValueTask CompleteAsync(CancellationToken ct = default) + { + var terminator = _framing!.GetTerminator(); + if (terminator.IsEmpty) + { + return default; + } + + _send!(terminator.Owner, terminator.Memory); + return default; + } + + public void Dispose() + { + _rental?.Dispose(); + _rental = null; + } +} diff --git a/src/TurboHTTP/Protocol/BodyHandle.cs b/src/TurboHTTP/Protocol/BodyHandle.cs deleted file mode 100644 index 0b130f29e..000000000 --- a/src/TurboHTTP/Protocol/BodyHandle.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.IO.Pipelines; - -namespace TurboHTTP.Protocol; - -internal sealed class BodyHandle(long maxBodySize) : IDisposable -{ - private readonly Pipe _pipe = new(); - private long _totalBytes; - private bool _completed; - - public void Feed(ReadOnlySpan data) - { - if (data.IsEmpty) - { - return; - } - - _totalBytes += data.Length; - if (_totalBytes > maxBodySize) - { - throw new HttpProtocolException($"Request body size {_totalBytes} exceeds limit {maxBodySize}."); - } - - var memory = _pipe.Writer.GetSpan(data.Length); - data.CopyTo(memory); - _pipe.Writer.Advance(data.Length); - _ = _pipe.Writer.FlushAsync(); - } - - public void Complete() - { - if (_completed) - { - return; - } - - _completed = true; - _pipe.Writer.Complete(); - } - - public void Abort(Exception reason) - { - if (_completed) - { - return; - } - - _completed = true; - _pipe.Writer.Complete(reason); - } - - public Stream AsStream() => _pipe.Reader.AsStream(); - - public void Dispose() - { - if (!_completed) - { - _completed = true; - _pipe.Writer.Complete(new ObjectDisposedException(nameof(BodyHandle))); - } - - _pipe.Reader.Complete(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/ContentHeaderClassifier.cs b/src/TurboHTTP/Protocol/ContentHeaderClassifier.cs index 54b8c0f35..73d274bbb 100644 --- a/src/TurboHTTP/Protocol/ContentHeaderClassifier.cs +++ b/src/TurboHTTP/Protocol/ContentHeaderClassifier.cs @@ -49,28 +49,57 @@ public static bool TryGetForbiddenCanonicalName(string name, out string canonica private static readonly Dictionary LowerCaseCache = new(StringComparer.OrdinalIgnoreCase) { - ["Content-Type"] = "content-type", - ["Content-Length"] = "content-length", - ["Content-Encoding"] = "content-encoding", - ["Content-Language"] = "content-language", - ["Content-Location"] = "content-location", - ["Content-Range"] = "content-range", - ["Content-Disposition"] = "content-disposition", - ["Cache-Control"] = "cache-control", - ["Date"] = "date", - ["Server"] = "server", - ["Set-Cookie"] = "set-cookie", - ["Transfer-Encoding"] = "transfer-encoding", - ["ETag"] = "etag", - ["Last-Modified"] = "last-modified", - ["Location"] = "location", - ["Vary"] = "vary", - ["Accept-Ranges"] = "accept-ranges", - ["Access-Control-Allow-Origin"] = "access-control-allow-origin", - ["Access-Control-Allow-Methods"] = "access-control-allow-methods", - ["Access-Control-Allow-Headers"] = "access-control-allow-headers", - ["X-Content-Type-Options"] = "x-content-type-options", - ["Strict-Transport-Security"] = "strict-transport-security", + [WellKnownHeaders.ContentType] = "content-type", + [WellKnownHeaders.ContentLength] = "content-length", + [WellKnownHeaders.ContentEncoding] = "content-encoding", + [WellKnownHeaders.ContentLanguage] = "content-language", + [WellKnownHeaders.ContentLocation] = "content-location", + [WellKnownHeaders.ContentRange] = "content-range", + [WellKnownHeaders.ContentDisposition] = "content-disposition", + [WellKnownHeaders.CacheControl] = "cache-control", + [WellKnownHeaders.Date] = "date", + [WellKnownHeaders.Server] = "server", + [WellKnownHeaders.SetCookie] = "set-cookie", + [WellKnownHeaders.TransferEncoding] = "transfer-encoding", + [WellKnownHeaders.ETag] = "etag", + [WellKnownHeaders.LastModified] = "last-modified", + [WellKnownHeaders.Location] = "location", + [WellKnownHeaders.Vary] = "vary", + [WellKnownHeaders.AcceptRanges] = "accept-ranges", + [WellKnownHeaders.AccessControlAllowOrigin] = "access-control-allow-origin", + [WellKnownHeaders.AccessControlAllowMethods] = "access-control-allow-methods", + [WellKnownHeaders.AccessControlAllowHeaders] = "access-control-allow-headers", + [WellKnownHeaders.XContentTypeOptions] = "x-content-type-options", + [WellKnownHeaders.StrictTransportSecurity] = "strict-transport-security", + // Standard request headers (RFC 9110) — avoids re-lowercasing on every client request. + [WellKnownHeaders.Host] = "host", + [WellKnownHeaders.UserAgent] = "user-agent", + [WellKnownHeaders.Accept] = "accept", + [WellKnownHeaders.AcceptEncoding] = "accept-encoding", + [WellKnownHeaders.AcceptLanguage] = "accept-language", + [WellKnownHeaders.AcceptCharset] = "accept-charset", + [WellKnownHeaders.Authorization] = "authorization", + [WellKnownHeaders.Cookie] = "cookie", + [WellKnownHeaders.Connection] = "connection", + [WellKnownHeaders.Referer] = "referer", + [WellKnownHeaders.Origin] = "origin", + [WellKnownHeaders.Range] = "range", + [WellKnownHeaders.Expect] = "expect", + [WellKnownHeaders.IfMatch] = "if-match", + [WellKnownHeaders.IfNoneMatch] = "if-none-match", + [WellKnownHeaders.IfModifiedSince] = "if-modified-since", + [WellKnownHeaders.IfUnmodifiedSince] = "if-unmodified-since", + [WellKnownHeaders.IfRange] = "if-range", + [WellKnownHeaders.Pragma] = "pragma", + [WellKnownHeaders.Te] = "te", + [WellKnownHeaders.UpgradeInsecureRequests] = "upgrade-insecure-requests", + [WellKnownHeaders.XForwardedFor] = "x-forwarded-for", + [WellKnownHeaders.XForwardedProto] = "x-forwarded-proto", + ["X-Forwarded-Host"] = "x-forwarded-host", + ["X-Requested-With"] = "x-requested-with", + [WellKnownHeaders.Forwarded] = "forwarded", + [WellKnownHeaders.From] = "from", + [WellKnownHeaders.MaxForwards] = "max-forwards" }; public static string ToLowerAscii(string name) @@ -85,10 +114,7 @@ public static string ToLowerAscii(string name) return name; } - return string.Create(name.Length, name, static (span, src) => - { - System.Text.Ascii.ToLower(src, span, out _); - }); + return string.Create(name.Length, name, static (span, src) => { System.Text.Ascii.ToLower(src, span, out _); }); } public static string JoinHeaderValues(IEnumerable values) @@ -108,7 +134,7 @@ public static string JoinHeaderValues(IEnumerable values) var second = enumerator.Current; if (!enumerator.MoveNext()) { - return string.Concat(first, ", ", second); + return string.Concat(first, WellKnownHeaders.CommaSpace, second); } var parts = new List(4) { first, second, enumerator.Current }; diff --git a/src/TurboHTTP/Protocol/DataRateMonitor.cs b/src/TurboHTTP/Protocol/DataRateMonitor.cs new file mode 100644 index 000000000..d669efefe --- /dev/null +++ b/src/TurboHTTP/Protocol/DataRateMonitor.cs @@ -0,0 +1,66 @@ +namespace TurboHTTP.Protocol; + +internal sealed class DataRateMonitor(double minDataRate, TimeSpan gracePeriod) +{ + private readonly long _gracePeriodMs = (long)gracePeriod.TotalMilliseconds; + private readonly Dictionary _states = new(); + + public bool Enabled => minDataRate > 0; + public int Count => _states.Count; + + public void Observe(long streamId, long bytes, long now) + { + if (!Enabled || bytes <= 0) + { + return; + } + + if (!_states.TryGetValue(streamId, out var state)) + { + state = new DataRateState { LastCheckTimestamp = now, GracePeriodStartTimestamp = now }; + _states[streamId] = state; + } + + state.TotalBytes += bytes; + } + + public void Remove(long streamId) => _states.Remove(streamId); + + public void Check(long now, List violations) + { + if (!Enabled) + { + return; + } + + foreach (var (streamId, state) in _states) + { + var elapsedMs = now - state.LastCheckTimestamp; + if (elapsedMs < 500) + { + continue; + } + + var rate = (state.TotalBytes - state.LastCheckBytes) / (elapsedMs / 1000.0); + state.LastCheckBytes = state.TotalBytes; + state.LastCheckTimestamp = now; + + if (rate < minDataRate) + { + if (!state.InGracePeriod) + { + state.InGracePeriod = true; + state.GracePeriodStartTimestamp = now; + } + else if (now - state.GracePeriodStartTimestamp > _gracePeriodMs) + { + violations.Add(streamId); + } + } + else + { + state.InGracePeriod = false; + } + } + } +} diff --git a/src/TurboHTTP/Protocol/DataRateState.cs b/src/TurboHTTP/Protocol/DataRateState.cs new file mode 100644 index 000000000..c77f6d1e0 --- /dev/null +++ b/src/TurboHTTP/Protocol/DataRateState.cs @@ -0,0 +1,19 @@ +namespace TurboHTTP.Protocol; + +internal sealed class DataRateState +{ + public long TotalBytes { get; set; } + public long LastCheckBytes { get; set; } + public long LastCheckTimestamp { get; set; } = Environment.TickCount64; + public long GracePeriodStartTimestamp { get; set; } = Environment.TickCount64; + public bool InGracePeriod { get; set; } + + public void Reset() + { + TotalBytes = 0; + LastCheckBytes = 0; + LastCheckTimestamp = Environment.TickCount64; + GracePeriodStartTimestamp = Environment.TickCount64; + InGracePeriod = false; + } +} diff --git a/src/TurboHTTP/Protocol/HttpMessageSize.cs b/src/TurboHTTP/Protocol/HttpMessageSize.cs index adac84347..e4fa4251b 100644 --- a/src/TurboHTTP/Protocol/HttpMessageSize.cs +++ b/src/TurboHTTP/Protocol/HttpMessageSize.cs @@ -8,7 +8,12 @@ namespace TurboHTTP.Protocol; internal static class HttpMessageSize { - private static readonly Http11ClientEncoderOptions DefaultOptions = new(); + private static readonly Http11ClientEncoderOptions DefaultOptions = new() + { + AutoHost = true, + AutoAcceptEncoding = true, + ChunkSize = 16 * 1024 + }; public static int Estimate(HttpRequestMessage request, int bodyLength) { diff --git a/src/TurboHTTP/Protocol/HttpProtocolException.cs b/src/TurboHTTP/Protocol/HttpProtocolException.cs index 689b1bc24..e2cbd9e00 100644 --- a/src/TurboHTTP/Protocol/HttpProtocolException.cs +++ b/src/TurboHTTP/Protocol/HttpProtocolException.cs @@ -2,4 +2,32 @@ namespace TurboHTTP.Protocol; -internal sealed class HttpProtocolException(string message) : TurboProtocolException(message); +/// +/// A protocol violation. For the line-based protocols (HTTP/1.0, 1.1) this is connection-fatal by +/// nature. For the multiplexed protocols (HTTP/2, 3) prefer the scoped subtypes +/// / , which carry the +/// wire error code so the session manager can emit an accurate GOAWAY/RST_STREAM and tear down. +/// +internal class HttpProtocolException(string message) : TurboProtocolException(message); + +/// +/// A connection-fatal protocol error (RFC 9113 §5.4.1 / RFC 9114 §8). The peer must be sent a +/// GOAWAY/CONNECTION_CLOSE carrying and the connection torn down. +/// is the raw wire value (an Http2ErrorCode or HTTP/3 error code). +/// +internal sealed class ConnectionProtocolException(int errorCode, string message) + : HttpProtocolException(message) +{ + public int ErrorCode { get; } = errorCode; +} + +/// +/// A stream-scoped protocol error (RFC 9113 §5.4.2 / RFC 9114 §8). The offending stream is reset with +/// while the connection survives. +/// +internal sealed class StreamProtocolException(int streamId, int errorCode, string message) + : HttpProtocolException(message) +{ + public int StreamId { get; } = streamId; + public int ErrorCode { get; } = errorCode; +} diff --git a/src/TurboHTTP/Protocol/IClientStateMachine.cs b/src/TurboHTTP/Protocol/IClientStateMachine.cs index 8c7a33e1f..e489fcd22 100644 --- a/src/TurboHTTP/Protocol/IClientStateMachine.cs +++ b/src/TurboHTTP/Protocol/IClientStateMachine.cs @@ -7,12 +7,15 @@ internal interface IClientStateMachine bool CanAcceptRequest { get; } bool HasInFlightRequests { get; } bool IsReconnecting { get; } + bool ShouldPauseNetwork => false; void PreStart(); void OnRequest(HttpRequestMessage request); + void OnRequestCancelled(HttpRequestMessage request) { } void DecodeServerData(ITransportInbound data); void OnUpstreamFinished(); void OnTimerFired(string name); void OnBodyMessage(object msg); + void OnOutboundFlushed() { } void Cleanup(); } diff --git a/src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs b/src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs index c444ff238..0a8ddbbcf 100644 --- a/src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs +++ b/src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs @@ -4,6 +4,5 @@ namespace TurboHTTP.Protocol; internal interface IProtocolSwitchCapable { - void RequestProtocolSwitch( - Func newSmFactory); -} + void RequestProtocolSwitch(Func newSmFactory); +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/IServerStateMachine.cs b/src/TurboHTTP/Protocol/IServerStateMachine.cs index 9274a9c24..b118e10be 100644 --- a/src/TurboHTTP/Protocol/IServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/IServerStateMachine.cs @@ -7,6 +7,7 @@ internal interface IServerStateMachine { bool CanAcceptResponse { get; } bool ShouldComplete { get; } + bool ShouldPauseNetwork => false; int MaxQueuedRequests { get; } void PreStart(); @@ -15,6 +16,8 @@ internal interface IServerStateMachine void OnDownstreamFinished(); void OnTimerFired(string name); void OnBodyMessage(object msg); + void OnOutboundFlushed() { } + void ResumeBody() { } void Cleanup(); } diff --git a/src/TurboHTTP/Protocol/LineBased/Body/BodyDecoderFactory.cs b/src/TurboHTTP/Protocol/LineBased/Body/BodyDecoderFactory.cs deleted file mode 100644 index 7fd5f5117..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/BodyDecoderFactory.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Buffers; -using TurboHTTP.Protocol.Semantics; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal static class BodyDecoderFactory -{ - public static IBodyDecoder Create( - BodyClassification classification, - long streamingThreshold, - MemoryPool pool, - long maxBufferedBodySize = 4_194_304, - long? maxStreamedBodySize = null, - long maxBodySize = 10_485_760) - { - switch (classification.Framing) - { - case BodyFraming.None: - return new ContentLengthBufferedDecoder(0, pool); - - case BodyFraming.Length: - { - var n = classification.ContentLength ?? 0; - if (n <= streamingThreshold && n <= maxBufferedBodySize) - { - return new ContentLengthBufferedDecoder((int)n, pool); - } - - var effectiveMax = maxStreamedBodySize ?? maxBodySize; - return new ContentLengthStreamedDecoder(n, effectiveMax); - } - - case BodyFraming.Chunked: - return new ChunkedBodyDecoder(maxStreamedBodySize ?? maxBodySize); - - case BodyFraming.Close: - return new CloseDelimitedBodyDecoder(maxStreamedBodySize ?? maxBodySize); - - default: - throw new ArgumentOutOfRangeException(nameof(classification)); - } - } -} diff --git a/src/TurboHTTP/Protocol/LineBased/Body/BodyEncoderFactory.cs b/src/TurboHTTP/Protocol/LineBased/Body/BodyEncoderFactory.cs deleted file mode 100644 index 46fc1fa5c..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/BodyEncoderFactory.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Net; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal static class BodyEncoderFactory -{ - public static IBodyEncoder? Create(Stream? bodyStream, long? contentLength, Version httpVersion) - { - if (bodyStream is null) - { - return null; - } - - if (httpVersion == HttpVersion.Version10) - { - return new ContentLengthBufferedBodyEncoder(); - } - - if (contentLength is not null) - { - return new ContentLengthStreamedBodyEncoder(); - } - - return new ChunkedBodyEncoder(); - } -} diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyDecoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyDecoder.cs deleted file mode 100644 index b437908f9..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyDecoder.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System.Globalization; -using System.Text; -using TurboHTTP.Protocol.Semantics; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal sealed class ChunkedBodyDecoder : IBodyDecoder -{ - private enum Phase - { - ChunkSize, - ChunkData, - ChunkDataCrlf, - Trailer, - Complete - } - - private readonly BodyHandle _handle; - private Phase _phase = Phase.ChunkSize; - private int _currentChunkRemaining; - private byte[] _stash = []; - private int _stashLen; - private List<(string Name, string Value)>? _trailers; - - - public bool IsBuffered => false; - public IReadOnlyList<(string Name, string Value)> Trailers => _trailers ?? (IReadOnlyList<(string Name, string Value)>)[]; - public bool IsComplete => _phase == Phase.Complete; - - public ChunkedBodyDecoder(long maxBodySize = 10_485_760) - { - _handle = new BodyHandle(maxBodySize); - } - - public bool Feed(ReadOnlySpan data, out int consumed) - { - consumed = 0; - if (_phase == Phase.Complete) - { - return true; - } - - ReadOnlySpan work; - var stashOffset = _stashLen; - if (_stashLen > 0) - { - EnsureStash(_stashLen + data.Length); - data.CopyTo(_stash.AsSpan(_stashLen)); - work = _stash.AsSpan(0, _stashLen + data.Length); - } - else - { - work = data; - } - - var pos = 0; - while (pos < work.Length) - { - switch (_phase) - { - case Phase.ChunkSize: - { - var crlf = BufferSearch.FindCrlf(work, pos); - if (crlf < 0) - { - goto stash; - } - - var line = work[pos..crlf]; - var semi = line.IndexOf((byte)';'); - var sizeSpan = semi < 0 ? line : line[..semi]; - if (!int.TryParse(Encoding.ASCII.GetString(sizeSpan), - NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _currentChunkRemaining)) - { - throw new HttpProtocolException("Invalid chunk size."); - } - - pos = crlf + 2; - _phase = _currentChunkRemaining == 0 ? Phase.Trailer : Phase.ChunkData; - break; - } - case Phase.ChunkData: - { - var avail = work.Length - pos; - var take = Math.Min(_currentChunkRemaining, avail); - if (take > 0) - { - _handle.Feed(work.Slice(pos, take)); - _currentChunkRemaining -= take; - pos += take; - } - - if (_currentChunkRemaining == 0) - { - _phase = Phase.ChunkDataCrlf; - } - else - { - goto stash; - } - - break; - } - case Phase.ChunkDataCrlf: - { - if (work.Length - pos < 2) - { - goto stash; - } - - if (work[pos] != (byte)'\r' || work[pos + 1] != (byte)'\n') - { - throw new HttpProtocolException("Missing CRLF after chunk-data."); - } - - pos += 2; - _phase = Phase.ChunkSize; - break; - } - case Phase.Trailer: - { - var crlf = BufferSearch.FindCrlf(work, pos); - if (crlf < 0) - { - goto stash; - } - - if (crlf == pos) - { - pos += 2; - _phase = Phase.Complete; - _handle.Complete(); - _stashLen = 0; - consumed = pos - stashOffset; - if (consumed < 0) - { - consumed = 0; - } - - return true; - } - - var trailerLine = work[pos..crlf]; - if (HeaderFieldParser.TryParse(trailerLine, out var fieldName, out var fieldValue) - && TrailerFieldValidator.IsAllowedInTrailer(fieldName)) - { - _trailers ??= []; - _trailers.Add((fieldName, fieldValue)); - } - - pos = crlf + 2; - break; - } - } - } - - stash: - var remaining = work.Length - pos; - if (remaining > 0) - { - EnsureStash(remaining); - work[pos..].CopyTo(_stash); - _stashLen = remaining; - } - else - { - _stashLen = 0; - } - - consumed = data.Length; - return false; - } - - private void EnsureStash(int needed) - { - if (_stash.Length < needed) - { - Array.Resize(ref _stash, Math.Max(needed, _stash.Length * 2 + 16)); - } - } - - public bool OnEof() - { - if (_phase != Phase.Complete) - { - _handle.Abort(new HttpProtocolException("Connection closed mid-chunk.")); - } - - return _phase == Phase.Complete; - } - - public int Drain(ReadOnlySpan data) - { - var consumed = 0; - if (_phase == Phase.Complete) - { - return 0; - } - - var beforePhase = _phase; - Feed(data, out consumed); - return consumed; - } - - public Stream GetBodyStream() => _handle.AsStream(); - - public void Dispose() - { - _handle.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyEncoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyEncoder.cs deleted file mode 100644 index fc738a6d6..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyEncoder.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Buffers; -using System.Globalization; -using Akka.Actor; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal sealed class ChunkedBodyEncoder : IBodyEncoder -{ - private readonly int _chunkSize; - private readonly CancellationTokenSource _cts = new(); - - public ChunkedBodyEncoder(int chunkSize = 16 * 1024) - { - _chunkSize = chunkSize; - } - - public void Start(Stream bodyStream, IActorRef stageActor) - { - _ = DrainAsync(new StreamContent(bodyStream), stageActor, _cts.Token); - } - - private async Task DrainAsync(HttpContent content, IActorRef stageActor, CancellationToken ct) - { - try - { - var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var dataBuffer = new byte[_chunkSize]; - - while (true) - { - var bytesRead = await stream.ReadAsync(dataBuffer, ct).ConfigureAwait(false); - if (bytesRead == 0) - { - break; - } - - stageActor.Tell(BuildChunk(dataBuffer.AsSpan(0, bytesRead))); - } - - stageActor.Tell(BuildTerminator()); - stageActor.Tell(new OutboundBodyComplete()); - } - catch (Exception ex) - { - stageActor.Tell(new OutboundBodyFailed(ex)); - } - } - - private static OutboundBodyChunk BuildChunk(ReadOnlySpan data) - { - var sizeHex = data.Length.ToString("x", CultureInfo.InvariantCulture); - // {hex}\r\n{data}\r\n - var totalLen = sizeHex.Length + 2 + data.Length + 2; - var owner = MemoryPool.Shared.Rent(totalLen); - var writer = SpanWriter.Create(owner.Memory.Span); - writer.WriteHex(data.Length); - writer.WriteCrlf(); - writer.WriteBytes(data); - writer.WriteCrlf(); - return new OutboundBodyChunk(owner, totalLen); - } - - private static OutboundBodyChunk BuildTerminator() - { - // 0\r\n\r\n - var owner = MemoryPool.Shared.Rent(5); - var writer = SpanWriter.Create(owner.Memory.Span); - writer.WriteBytes(WellKnownHeaders.ZeroValue); - writer.WriteCrlf(); - writer.WriteCrlf(); - return new OutboundBodyChunk(owner, writer.BytesWritten); - } - - public void Dispose() - { - _cts.Cancel(); - _cts.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/LineBased/Body/CloseDelimitedBodyDecoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/CloseDelimitedBodyDecoder.cs deleted file mode 100644 index f26ec4747..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/CloseDelimitedBodyDecoder.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace TurboHTTP.Protocol.LineBased.Body; - -internal sealed class CloseDelimitedBodyDecoder : IBodyDecoder -{ - private readonly BodyHandle _handle; - - public bool IsBuffered => false; - public IReadOnlyList<(string Name, string Value)> Trailers => []; - public bool IsComplete => false; - - public CloseDelimitedBodyDecoder(long maxBodySize = 10_485_760) - { - _handle = new BodyHandle(maxBodySize); - } - - public bool Feed(ReadOnlySpan data, out int consumed) - { - if (data.Length > 0) - { - _handle.Feed(data); - } - - consumed = data.Length; - return false; - } - - public bool OnEof() - { - _handle.Complete(); - return true; - } - - public int Drain(ReadOnlySpan data) - { - return 0; - } - - public Stream GetBodyStream() => _handle.AsStream(); - - public void Dispose() - { - _handle.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoder.cs deleted file mode 100644 index 369ea72ca..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoder.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Buffers; -using Akka.Actor; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal sealed class ContentLengthBufferedBodyEncoder : IBodyEncoder -{ - private readonly CancellationTokenSource _cts = new(); - - public void Start(Stream bodyStream, IActorRef stageActor) - { - _ = DrainAsync(new StreamContent(bodyStream), stageActor, _cts.Token); - } - - private static async Task DrainAsync(HttpContent content, IActorRef stageActor, CancellationToken ct) - { - try - { - var bytes = await content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); - var owner = MemoryPool.Shared.Rent(bytes.Length); - bytes.CopyTo(owner.Memory.Span); - stageActor.Tell(new OutboundBodyChunk(owner, bytes.Length)); - stageActor.Tell(new OutboundBodyComplete()); - } - catch (Exception ex) - { - stageActor.Tell(new OutboundBodyFailed(ex)); - } - } - - public void Dispose() - { - _cts.Cancel(); - _cts.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedDecoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedDecoder.cs deleted file mode 100644 index 6a5db716c..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedDecoder.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Buffers; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal sealed class ContentLengthBufferedDecoder : IBodyDecoder -{ - private readonly int _expected; - private readonly IMemoryOwner _owner; - private int _received; - private bool _complete; - - public bool IsBuffered => true; - public IReadOnlyList<(string Name, string Value)> Trailers => []; - public bool IsComplete => _complete; - - public ContentLengthBufferedDecoder(int expected, MemoryPool pool) - { - ArgumentOutOfRangeException.ThrowIfNegative(expected); - _expected = expected; - _owner = pool.Rent(Math.Max(expected, 1)); - _complete = expected == 0; - } - - public bool Feed(ReadOnlySpan data, out int consumed) - { - var need = _expected - _received; - var take = Math.Min(need, data.Length); - if (take > 0) - { - data[..take].CopyTo(_owner.Memory.Span[_received..]); - _received += take; - } - - consumed = take; - _complete = _received == _expected; - return _complete; - } - - public bool OnEof() => _complete; - - public int Drain(ReadOnlySpan data) - { - if (_complete) - { - return 0; - } - - var need = _expected - _received; - var take = Math.Min(need, data.Length); - if (take > 0) - { - _received += take; - } - - _complete = _received == _expected; - return take; - } - - public Stream GetBodyStream() => new MemoryStream(_owner.Memory[.._expected].ToArray()); - - public void Dispose() - { - _owner.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoder.cs deleted file mode 100644 index 3afa0e26e..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoder.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Buffers; -using Akka.Actor; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal sealed class ContentLengthStreamedBodyEncoder : IBodyEncoder -{ - private readonly int _chunkSize; - private readonly CancellationTokenSource _cts = new(); - - public ContentLengthStreamedBodyEncoder(int chunkSize = 16 * 1024) - { - _chunkSize = chunkSize; - } - - public void Start(Stream bodyStream, IActorRef stageActor) - { - _ = DrainAsync(new StreamContent(bodyStream), stageActor, _cts.Token); - } - - private async Task DrainAsync(HttpContent content, IActorRef stageActor, CancellationToken ct) - { - try - { - var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); - while (true) - { - var owner = MemoryPool.Shared.Rent(_chunkSize); - var bytesRead = await stream.ReadAsync(owner.Memory[.._chunkSize], ct).ConfigureAwait(false); - if (bytesRead == 0) - { - owner.Dispose(); - break; - } - - stageActor.Tell(new OutboundBodyChunk(owner, bytesRead)); - } - - stageActor.Tell(new OutboundBodyComplete()); - } - catch (Exception ex) - { - stageActor.Tell(new OutboundBodyFailed(ex)); - } - } - - public void Dispose() - { - _cts.Cancel(); - _cts.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedDecoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedDecoder.cs deleted file mode 100644 index 16e55d5a9..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedDecoder.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace TurboHTTP.Protocol.LineBased.Body; - -internal sealed class ContentLengthStreamedDecoder : IBodyDecoder -{ - private readonly long _expected; - private readonly BodyHandle _handle; - private long _received; - private bool _complete; - - public bool IsBuffered => false; - public IReadOnlyList<(string Name, string Value)> Trailers => []; - public bool IsComplete => _complete; - - public ContentLengthStreamedDecoder(long expected, long maxBodySize = 10_485_760) - { - ArgumentOutOfRangeException.ThrowIfNegative(expected); - _expected = expected; - _handle = new BodyHandle(maxBodySize); - _complete = expected == 0; - if (_complete) - { - _handle.Complete(); - } - } - - public bool Feed(ReadOnlySpan data, out int consumed) - { - if (_complete) - { - consumed = 0; - return true; - } - - var need = (int)Math.Min(int.MaxValue, _expected - _received); - var take = Math.Min(need, data.Length); - if (take > 0) - { - _handle.Feed(data[..take]); - _received += take; - } - - consumed = take; - _complete = _received == _expected; - if (_complete) - { - _handle.Complete(); - } - - return _complete; - } - - public bool OnEof() - { - if (!_complete) - { - _handle.Abort(new HttpProtocolException("Connection closed before content-length satisfied.")); - } - - return _complete; - } - - public int Drain(ReadOnlySpan data) - { - if (_complete) - { - return 0; - } - - var need = (int)Math.Min(int.MaxValue, _expected - _received); - var take = Math.Min(need, data.Length); - if (take > 0) - { - _received += take; - } - - _complete = _received == _expected; - if (_complete) - { - _handle.Complete(); - } - - return take; - } - - public Stream GetBodyStream() => _handle.AsStream(); - - public void Dispose() - { - _handle.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/LineBased/Body/IBodyDecoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/IBodyDecoder.cs deleted file mode 100644 index 3183e17fe..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/IBodyDecoder.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace TurboHTTP.Protocol.LineBased.Body; - -internal interface IBodyDecoder : IDisposable -{ - bool IsBuffered { get; } - IReadOnlyList<(string Name, string Value)> Trailers { get; } - bool IsComplete { get; } - bool Feed(ReadOnlySpan data, out int consumed); - bool OnEof(); - int Drain(ReadOnlySpan data); - Stream GetBodyStream(); -} diff --git a/src/TurboHTTP/Protocol/LineBased/Body/IBodyEncoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/IBodyEncoder.cs deleted file mode 100644 index 079c81dd7..000000000 --- a/src/TurboHTTP/Protocol/LineBased/Body/IBodyEncoder.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Akka.Actor; - -namespace TurboHTTP.Protocol.LineBased.Body; - -internal interface IBodyEncoder : IDisposable -{ - void Start(Stream bodyStream, IActorRef stageActor); -} diff --git a/src/TurboHTTP/Protocol/LineBased/HeaderBlockReader.cs b/src/TurboHTTP/Protocol/LineBased/HeaderBlockReader.cs index 239de3fad..1db1e22d2 100644 --- a/src/TurboHTTP/Protocol/LineBased/HeaderBlockReader.cs +++ b/src/TurboHTTP/Protocol/LineBased/HeaderBlockReader.cs @@ -5,27 +5,15 @@ namespace TurboHTTP.Protocol.LineBased; internal enum HeaderBlockResult { NeedMore, - Complete, + Complete } -internal sealed class HeaderBlockReader +internal sealed class HeaderBlockReader(int maxHeaderBytes, int maxHeaderCount, int maxLineLength, bool allowObsFold) { - private readonly int _maxHeaderBytes; - private readonly int _maxHeaderCount; - private readonly int _maxLineLength; - private readonly bool _allowObsFold; private readonly HeaderCollection _headers = new(); private int _totalBytes; private int _headerCount; - public HeaderBlockReader(int maxHeaderBytes, int maxHeaderCount, int maxLineLength, bool allowObsFold) - { - _maxHeaderBytes = maxHeaderBytes; - _maxHeaderCount = maxHeaderCount; - _maxLineLength = maxLineLength; - _allowObsFold = allowObsFold; - } - public HeaderCollection GetHeaders() => _headers; public void Reset() @@ -54,22 +42,22 @@ public HeaderBlockResult Feed(ReadOnlySpan data, out int consumed) return HeaderBlockResult.Complete; } - if (lineLen > _maxLineLength) + if (lineLen > maxLineLength) { - throw new HttpProtocolException($"Header line exceeds {_maxLineLength} bytes."); + throw new HttpProtocolException($"Header line exceeds {maxLineLength} bytes."); } _totalBytes += lineLen + 2; - if (_totalBytes > _maxHeaderBytes) + if (_totalBytes > maxHeaderBytes) { - throw new HttpProtocolException($"Header block exceeds {_maxHeaderBytes} bytes."); + throw new HttpProtocolException($"Header block exceeds {maxHeaderBytes} bytes."); } var line = data.Slice(pos, lineLen); if (line[0] == (byte)' ' || line[0] == (byte)'\t') { - if (!_allowObsFold) + if (!allowObsFold) { throw new HttpProtocolException("obs-fold not permitted in header block."); } @@ -79,9 +67,9 @@ public HeaderBlockResult Feed(ReadOnlySpan data, out int consumed) } _headerCount++; - if (_headerCount > _maxHeaderCount) + if (_headerCount > maxHeaderCount) { - throw new HttpProtocolException($"Header count exceeds {_maxHeaderCount}."); + throw new HttpProtocolException($"Header count exceeds {maxHeaderCount}."); } if (!HeaderFieldParser.TryParse(line, out var name, out var value)) diff --git a/src/TurboHTTP/Protocol/LineBased/RequestLineParser.cs b/src/TurboHTTP/Protocol/LineBased/RequestLineParser.cs index 910917793..00594dfad 100644 --- a/src/TurboHTTP/Protocol/LineBased/RequestLineParser.cs +++ b/src/TurboHTTP/Protocol/LineBased/RequestLineParser.cs @@ -80,7 +80,7 @@ 5 when span.SequenceEqual(WellKnownHeaders.Trace) => HttpMethod.Trace, 6 when span.SequenceEqual(WellKnownHeaders.Delete) => HttpMethod.Delete, 7 when span.SequenceEqual(WellKnownHeaders.Options) => HttpMethod.Options, 7 when span.SequenceEqual(WellKnownHeaders.Connect) => HttpMethod.Connect, - _ => new HttpMethod(Encoding.ASCII.GetString(span)), + _ => new HttpMethod(Encoding.ASCII.GetString(span)) }; } } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyDecoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyDecoder.cs deleted file mode 100644 index ecc1d322a..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyDecoder.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Buffers; - -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal sealed class BufferedBodyDecoder : IBodyDecoder -{ - private readonly MemoryPool _pool = MemoryPool.Shared; - private IMemoryOwner? _owner; - private int _length; - - public bool IsBuffered => true; - public bool IsComplete { get; private set; } - - public void Feed(ReadOnlySpan data, bool endStream) - { - if (!data.IsEmpty) - { - EnsureCapacity(_length + data.Length); - data.CopyTo(_owner!.Memory.Span[_length..]); - _length += data.Length; - } - - if (endStream) - { - IsComplete = true; - } - } - - public Stream GetBodyStream() - { - if (_length == 0) - { - return Stream.Null; - } - - var bytes = _owner!.Memory[.._length].ToArray(); - return new MemoryStream(bytes, writable: false); - } - - public void Abort() - { - Dispose(); - } - - public void Dispose() - { - _owner?.Dispose(); - _owner = null; - } - - private void EnsureCapacity(int needed) - { - if (_owner != null && _owner.Memory.Length >= needed) - { - return; - } - - var newSize = Math.Max(needed, (_owner?.Memory.Length ?? 256) * 2); - var newOwner = _pool.Rent(newSize); - - if (_owner != null && _length > 0) - { - _owner.Memory[.._length].CopyTo(newOwner.Memory); - } - - _owner?.Dispose(); - _owner = newOwner; - } -} diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyEncoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyEncoder.cs deleted file mode 100644 index 9bb32c24f..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyEncoder.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Buffers; - -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal sealed class BufferedBodyEncoder : IBodyEncoder -{ - private readonly CancellationTokenSource _cts = new(); - - public void Start(Stream bodyStream, Action onMessage) => _ = DrainAsync(new StreamContent(bodyStream), onMessage, _cts.Token); - - private static async Task DrainAsync(HttpContent content, Action onMessage, CancellationToken ct) - { - try - { - var bytes = await content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); - var owner = MemoryPool.Shared.Rent(bytes.Length); - bytes.CopyTo(owner.Memory.Span); - onMessage(new OutboundBodyChunk(owner, bytes.Length)); - onMessage(new OutboundBodyComplete()); - } - catch (Exception ex) - { - onMessage(new OutboundBodyFailed(ex)); - } - } - - public void Dispose() - { - _cts.Cancel(); - _cts.Dispose(); - } -} diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/IBodyDecoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/IBodyDecoder.cs deleted file mode 100644 index 04dd37737..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/IBodyDecoder.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal interface IBodyDecoder : IDisposable -{ - bool IsBuffered { get; } - bool IsComplete { get; } - void Feed(ReadOnlySpan data, bool endStream); - Stream GetBodyStream(); - void Abort(); -} diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/IBodyEncoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/IBodyEncoder.cs deleted file mode 100644 index d9c50c069..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/IBodyEncoder.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal interface IBodyEncoder : IDisposable -{ - void Start(Stream bodyStream, Action onMessage); -} diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyDecoderFactory.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyDecoderFactory.cs deleted file mode 100644 index 07f62c40c..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyDecoderFactory.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal static class BodyDecoderFactory -{ - public static IBodyDecoder Create(bool streaming) - { - return streaming - ? new StreamingBodyDecoder() - : new BufferedBodyDecoder(); - } -} diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyEncoderFactory.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyEncoderFactory.cs deleted file mode 100644 index 100360545..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyEncoderFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal static class BodyEncoderFactory -{ - public static IBodyEncoder? Create(Stream? bodyStream, long? contentLength) - { - if (bodyStream is null) - { - return null; - } - - if (contentLength is not null) - { - return new BufferedBodyEncoder(); - } - - return new StreamingBodyEncoder(); - } -} diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamBodyMessages.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/StreamBodyMessages.cs deleted file mode 100644 index 60d47d87d..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamBodyMessages.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Buffers; - -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal sealed record StreamBodyChunk(T StreamId, IMemoryOwner Owner, int Length, int Offset = 0) -{ - public ReadOnlyMemory Data => Owner.Memory.Slice(Offset, Length); -} - -internal sealed record StreamBodyComplete(T StreamId); - -internal sealed record StreamBodyFailed(T StreamId, Exception Reason); \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyDecoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyDecoder.cs deleted file mode 100644 index b05cb2296..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyDecoder.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal sealed class StreamingBodyDecoder : IBodyDecoder -{ - private readonly BodyHandle _handle; - - public StreamingBodyDecoder(long maxBodySize = long.MaxValue) - { - _handle = new BodyHandle(maxBodySize); - } - - public bool IsBuffered => false; - public bool IsComplete { get; private set; } - - public void Feed(ReadOnlySpan data, bool endStream) - { - if (!data.IsEmpty) - { - _handle.Feed(data); - } - - if (endStream) - { - IsComplete = true; - _handle.Complete(); - } - } - - public Stream GetBodyStream() - { - return _handle.AsStream(); - } - - public void Abort() - { - _handle.Abort(new OperationCanceledException()); - } - - public void Dispose() - { - _handle.Dispose(); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyEncoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyEncoder.cs deleted file mode 100644 index a32e55af7..000000000 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyEncoder.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Buffers; - -namespace TurboHTTP.Protocol.Multiplexed.Body; - -internal sealed class StreamingBodyEncoder : IBodyEncoder -{ - private readonly int _chunkSize; - private readonly CancellationTokenSource _cts = new(); - - public StreamingBodyEncoder(int chunkSize = 16 * 1024) - { - _chunkSize = chunkSize; - } - - public void Start(Stream bodyStream, Action onMessage) => _ = DrainAsync(new StreamContent(bodyStream), onMessage, _cts.Token); - - private async Task DrainAsync(HttpContent content, Action onMessage, CancellationToken ct) - { - try - { - var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); - while (true) - { - var owner = MemoryPool.Shared.Rent(_chunkSize); - var bytesRead = await stream.ReadAsync(owner.Memory[.._chunkSize], ct).ConfigureAwait(false); - if (bytesRead == 0) - { - owner.Dispose(); - break; - } - - onMessage(new OutboundBodyChunk(owner, bytesRead)); - } - - onMessage(new OutboundBodyComplete()); - } - catch (Exception ex) - { - onMessage(new OutboundBodyFailed(ex)); - } - } - - public void Dispose() - { - _cts.Cancel(); - _cts.Dispose(); - } -} diff --git a/src/TurboHTTP/Protocol/Multiplexed/QuicStreamTracker.cs b/src/TurboHTTP/Protocol/Multiplexed/QuicStreamTracker.cs index 7981e6b75..c3ac612b5 100644 --- a/src/TurboHTTP/Protocol/Multiplexed/QuicStreamTracker.cs +++ b/src/TurboHTTP/Protocol/Multiplexed/QuicStreamTracker.cs @@ -1,18 +1,13 @@ namespace TurboHTTP.Protocol.Multiplexed; -internal sealed class QuicStreamTracker : IStreamTracker +internal sealed class QuicStreamTracker(long initialNextStreamId, int maxConcurrentStreams) + : IStreamTracker { private readonly HashSet _activeStreamIds = []; - public QuicStreamTracker(long initialNextStreamId = 0, int maxConcurrentStreams = 100) - { - NextStreamId = initialNextStreamId; - MaxConcurrentStreams = maxConcurrentStreams; - } - public int ActiveStreamCount { get; private set; } - public int MaxConcurrentStreams { get; private set; } - public long NextStreamId { get; private set; } + public int MaxConcurrentStreams { get; private set; } = maxConcurrentStreams; + public long NextStreamId { get; private set; } = initialNextStreamId; public bool CanOpenStream() => ActiveStreamCount < MaxConcurrentStreams; diff --git a/src/TurboHTTP/Protocol/Multiplexed/ReconnectionManager.cs b/src/TurboHTTP/Protocol/Multiplexed/ReconnectionManager.cs index c4c0e03d7..e619fb542 100644 --- a/src/TurboHTTP/Protocol/Multiplexed/ReconnectionManager.cs +++ b/src/TurboHTTP/Protocol/Multiplexed/ReconnectionManager.cs @@ -1,18 +1,10 @@ namespace TurboHTTP.Protocol.Multiplexed; -internal sealed class ReconnectionManager +internal sealed class ReconnectionManager(int maxAttempts, int maxBufferSize = int.MaxValue) { - private readonly int _maxAttempts; - private readonly int _maxBufferSize; private readonly List _buffer = []; private int _attempts; - public ReconnectionManager(int maxAttempts, int maxBufferSize = int.MaxValue) - { - _maxAttempts = maxAttempts; - _maxBufferSize = maxBufferSize; - } - public bool IsReconnecting { get; private set; } public int BufferedCount => _buffer.Count; @@ -35,7 +27,7 @@ public IReadOnlyList OnConnectionRestored() public bool OnReconnectAttemptFailed() { - if (_attempts >= _maxAttempts) + if (_attempts >= maxAttempts) { IsReconnecting = false; _attempts = 0; @@ -48,7 +40,7 @@ public bool OnReconnectAttemptFailed() public bool Buffer(HttpRequestMessage request) { - if (_buffer.Count >= _maxBufferSize) + if (_buffer.Count >= maxBufferSize) { return false; } diff --git a/src/TurboHTTP/Protocol/Multiplexed/StackStreamStatePool.cs b/src/TurboHTTP/Protocol/Multiplexed/StackStreamStatePool.cs index 5083c89af..69b3574b4 100644 --- a/src/TurboHTTP/Protocol/Multiplexed/StackStreamStatePool.cs +++ b/src/TurboHTTP/Protocol/Multiplexed/StackStreamStatePool.cs @@ -1,25 +1,18 @@ namespace TurboHTTP.Protocol.Multiplexed; -internal sealed class StackStreamStatePool : IStreamStatePool where TState : class +internal sealed class StackStreamStatePool(int maxCapacity, Func factory) : IStreamStatePool + where TState : class { private readonly Stack _pool = new(); - private readonly int _maxCapacity; - private readonly Func _factory; - - public StackStreamStatePool(int maxCapacity, Func factory) - { - _maxCapacity = maxCapacity; - _factory = factory; - } public TState Rent() { - return _pool.Count > 0 ? _pool.Pop() : _factory(); + return _pool.Count > 0 ? _pool.Pop() : factory(); } public void Return(TState state) { - if (_pool.Count < _maxCapacity) + if (_pool.Count < maxCapacity) { _pool.Push(state); } diff --git a/src/TurboHTTP/Protocol/OutboundBodyMessages.cs b/src/TurboHTTP/Protocol/OutboundBodyMessages.cs index d99a51677..98eeb8bfb 100644 --- a/src/TurboHTTP/Protocol/OutboundBodyMessages.cs +++ b/src/TurboHTTP/Protocol/OutboundBodyMessages.cs @@ -1,7 +1,3 @@ -using System.Buffers; - namespace TurboHTTP.Protocol; -internal sealed record OutboundBodyChunk(IMemoryOwner Owner, int Length); -internal sealed record OutboundBodyComplete; -internal sealed record OutboundBodyFailed(Exception Reason); +internal sealed record BodyResumed; diff --git a/src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs b/src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs index 51cdb6502..b798fc510 100644 --- a/src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs +++ b/src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs @@ -77,14 +77,17 @@ private void OnWaitingForConnect(ITransportInbound data) if (security?.ApplicationProtocol == SslApplicationProtocol.Http2) { - Activate(ops => new Http2ServerStateMachine(_options, ops)); + var h2Options = _options.ToHttp2Options(); + Activate(ops => new Http2ServerStateMachine(h2Options, ops)); _inner!.DecodeClientData(data); return; } if (security is not null) { - Activate(ops => new Http11ServerStateMachine(_options, ops)); + var h1Options = _options.ToHttp1Options(); + var h2UpgradeOptions = _options.ToHttp2Options(); + Activate(ops => new Http11ServerStateMachine(h1Options, h2UpgradeOptions, ops)); _inner!.DecodeClientData(data); return; } @@ -113,18 +116,22 @@ private void OnSniffing(ITransportInbound data) if (span.StartsWith(Http2PrefixMagic)) { - Activate(ops => new Http2ServerStateMachine(_options, ops)); + var h2Options = _options.ToHttp2Options(); + Activate(ops => new Http2ServerStateMachine(h2Options, ops)); ReplayBuffered(); return; } if (DetectHttp10()) { - Activate(ops => new Http10ServerStateMachine(_options, ops)); + var h1Options = _options.ToHttp1Options(); + Activate(ops => new Http10ServerStateMachine(h1Options, ops)); } else if (ContainsRequestLineCrlf()) { - Activate(ops => new Http11ServerStateMachine(_options, ops)); + var h1Options = _options.ToHttp1Options(); + var h2UpgradeOptions = _options.ToHttp2Options(); + Activate(ops => new Http11ServerStateMachine(h1Options, h2UpgradeOptions, ops)); } else { @@ -198,31 +205,23 @@ internal void HandleUpgrade(Func ne _inner.PreStart(); } - private sealed class UpgradeAwareOps : IServerStageOperations, IProtocolSwitchCapable + private sealed class UpgradeAwareOps(IServerStageOperations real, ProtocolNegotiatingStateMachine parent) + : IServerStageOperations, IProtocolSwitchCapable { - private readonly IServerStageOperations _real; - private readonly ProtocolNegotiatingStateMachine _parent; - - public UpgradeAwareOps(IServerStageOperations real, ProtocolNegotiatingStateMachine parent) - { - _real = real; - _parent = parent; - } - - public void OnRequest(IFeatureCollection features) => _real.OnRequest(features); - public void OnOutbound(ITransportOutbound item) => _real.OnOutbound(item); - public void OnScheduleTimer(string name, TimeSpan delay) => _real.OnScheduleTimer(name, delay); - public void OnCancelTimer(string name) => _real.OnCancelTimer(name); - public ILoggingAdapter Log => _real.Log; - public IActorRef StageActor => _real.StageActor; - public Akka.Streams.IMaterializer Materializer => _real.Materializer; - public IServiceProvider? Services => _real.Services; - public TurboHttpConnectionFeature? ConnectionFeature => _real.ConnectionFeature; - public TlsHandshakeFeature? TlsHandshakeFeature => _real.TlsHandshakeFeature; + public void OnRequest(IFeatureCollection features) => real.OnRequest(features); + public void OnOutbound(ITransportOutbound item) => real.OnOutbound(item); + public void OnScheduleTimer(string name, TimeSpan delay) => real.OnScheduleTimer(name, delay); + public void OnCancelTimer(string name) => real.OnCancelTimer(name); + public ILoggingAdapter Log => real.Log; + public IActorRef StageActor => real.StageActor; + public Akka.Streams.IMaterializer Materializer => real.Materializer; + public IServiceProvider? Services => real.Services; + public TurboHttpConnectionFeature? ConnectionFeature => real.ConnectionFeature; + public TlsHandshakeFeature? TlsHandshakeFeature => real.TlsHandshakeFeature; public void RequestProtocolSwitch(Func newSmFactory) { - _parent.HandleUpgrade(newSmFactory); + parent.HandleUpgrade(newSmFactory); } } } diff --git a/src/TurboHTTP/Protocol/Semantics/BodySemantics.cs b/src/TurboHTTP/Protocol/Semantics/BodySemantics.cs index a6ba3a96d..bf8594be0 100644 --- a/src/TurboHTTP/Protocol/Semantics/BodySemantics.cs +++ b/src/TurboHTTP/Protocol/Semantics/BodySemantics.cs @@ -7,19 +7,13 @@ internal enum BodyFraming None, Length, Chunked, - Close, + Close } -internal readonly struct BodyClassification +internal readonly struct BodyClassification(BodyFraming framing, long? contentLength) { - public BodyFraming Framing { get; } - public long? ContentLength { get; } - - public BodyClassification(BodyFraming framing, long? contentLength) - { - Framing = framing; - ContentLength = contentLength; - } + public BodyFraming Framing { get; } = framing; + public long? ContentLength { get; } = contentLength; } internal static class BodySemantics @@ -36,7 +30,7 @@ public static BodyClassification ClassifyResponse( return new BodyClassification(BodyFraming.None, null); } - if (!ContentLengthSemantics.BodyRequired((HttpStatusCode)statusCode, "GET")) + if (!ContentLengthSemantics.BodyRequired((HttpStatusCode)statusCode, WellKnownHeaders.Get)) { return new BodyClassification(BodyFraming.None, null); } @@ -73,10 +67,24 @@ private static BodyClassification ClassifyFraming( throw new HttpProtocolException("Transfer-Encoding not allowed in HTTP/1.0 messages."); } - if (te.Contains(WellKnownHeaders.ChunkedValue, StringComparison.OrdinalIgnoreCase)) + // RFC 9112 §6.1: chunked MUST be the final transfer coding and applied only once. + // A substring match ("chunked, gzip", "x-chunked-foo") would let a body of unknown length + // be parsed as the next request (request smuggling), so tokenize and inspect the final coding. + if (FinalCodingIsChunked(te)) { return new BodyClassification(BodyFraming.Chunked, null); } + + // Transfer-Encoding present but the final coding is not chunked. + // Request: length is unreliable — reject (400). Response: length is determined by + // reading until the connection closes (RFC 9112 §6.1). + if (!isResponse) + { + throw new HttpProtocolException( + "Transfer-Encoding present but the final coding is not 'chunked'; rejected as unreliable body length (RFC 9112 §6.1)."); + } + + return new BodyClassification(BodyFraming.Close, null); } if (cl is not null) @@ -98,6 +106,31 @@ private static BodyClassification ClassifyFraming( return new BodyClassification(BodyFraming.None, null); } + /// + /// Returns true only when the comma-separated transfer-coding list ends with exactly "chunked" + /// and "chunked" appears nowhere else (RFC 9112 §6.1: applied once, as the final coding). + /// + private static bool FinalCodingIsChunked(string transferEncoding) + { + var codings = transferEncoding.Split(','); + var finalIndex = codings.Length - 1; + + if (!codings[finalIndex].Trim().Equals(WellKnownHeaders.ChunkedValue, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + for (var i = 0; i < finalIndex; i++) + { + if (codings[i].Trim().Equals(WellKnownHeaders.ChunkedValue, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + private static string NormalizeContentLength(string combined) { if (!combined.Contains(',')) diff --git a/src/TurboHTTP/Protocol/Semantics/ConnectionHeaderSemantics.cs b/src/TurboHTTP/Protocol/Semantics/ConnectionHeaderSemantics.cs index 923d46b43..eccee4466 100644 --- a/src/TurboHTTP/Protocol/Semantics/ConnectionHeaderSemantics.cs +++ b/src/TurboHTTP/Protocol/Semantics/ConnectionHeaderSemantics.cs @@ -47,7 +47,7 @@ public static bool HasCloseOption(string? headerValue) foreach (var part in parts) { var trimmed = part.Trim(); - if (string.Equals(trimmed, "close", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(trimmed, WellKnownHeaders.CloseValue, StringComparison.OrdinalIgnoreCase)) { return true; } @@ -70,7 +70,7 @@ public static bool HasUpgradeOption(string? headerValue) foreach (var part in parts) { var trimmed = part.Trim(); - if (string.Equals(trimmed, "upgrade", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(trimmed, WellKnownHeaders.Upgrade, StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/src/TurboHTTP/Protocol/Semantics/ContentEncodingSupport.cs b/src/TurboHTTP/Protocol/Semantics/ContentEncodingSupport.cs index 75a618823..11a754394 100644 --- a/src/TurboHTTP/Protocol/Semantics/ContentEncodingSupport.cs +++ b/src/TurboHTTP/Protocol/Semantics/ContentEncodingSupport.cs @@ -6,7 +6,7 @@ namespace TurboHTTP.Protocol.Semantics; /// internal static class ContentEncodingSupport { - private static readonly string[] SupportedCodings = ["gzip", "deflate", "br", "identity"]; + private static readonly string[] SupportedCodings = [WellKnownHeaders.GzipValue, WellKnownHeaders.DeflateValue, WellKnownHeaders.BrValue, WellKnownHeaders.IdentityValue]; private static readonly IReadOnlyList SupportedCodingsList = SupportedCodings.AsReadOnly(); /// diff --git a/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs b/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs index cb4ba9a9b..fddfcd54b 100644 --- a/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs +++ b/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs @@ -3,16 +3,10 @@ namespace TurboHTTP.Protocol.Semantics; -internal readonly struct HeaderEntry +internal readonly struct HeaderEntry(string name, string value) { - public string Name { get; } - public string Value { get; } - - public HeaderEntry(string name, string value) - { - Name = name; - Value = value; - } + public string Name { get; } = name; + public string Value { get; } = value; } internal sealed class HeaderCollection : IEnumerable diff --git a/src/TurboHTTP/Protocol/Semantics/HeaderValidation.cs b/src/TurboHTTP/Protocol/Semantics/HeaderValidation.cs index fd754ac9d..222099609 100644 --- a/src/TurboHTTP/Protocol/Semantics/HeaderValidation.cs +++ b/src/TurboHTTP/Protocol/Semantics/HeaderValidation.cs @@ -69,6 +69,17 @@ public static string TrimOws(string value) return start == 0 && end == value.Length ? value : value[start..end]; } + public static bool IsTokenChar(byte b) + { + return b switch + { + >= (byte)'A' and <= (byte)'Z' or >= (byte)'a' and <= (byte)'z' or >= (byte)'0' and <= (byte)'9' => true, + _ => b is (byte)'!' or (byte)'#' or (byte)'$' or (byte)'%' or (byte)'&' or (byte)'\'' + or (byte)'*' or (byte)'+' or (byte)'-' or (byte)'.' or (byte)'^' or (byte)'_' + or (byte)'`' or (byte)'|' or (byte)'~' + }; + } + private static bool IsTokenChar(char c) { return c switch diff --git a/src/TurboHTTP/Protocol/Semantics/IfRangeValidator.cs b/src/TurboHTTP/Protocol/Semantics/IfRangeValidator.cs index d41bd0a40..0537b0699 100644 --- a/src/TurboHTTP/Protocol/Semantics/IfRangeValidator.cs +++ b/src/TurboHTTP/Protocol/Semantics/IfRangeValidator.cs @@ -18,7 +18,7 @@ internal static class IfRangeValidator "r", // RFC 1123 "dddd, dd-MMM-yy HH:mm:ss 'GMT'", // RFC 850 "ddd MMM d HH:mm:ss yyyy", // asctime - "ddd MMM dd HH:mm:ss yyyy", // asctime (two-digit day) + "ddd MMM dd HH:mm:ss yyyy" // asctime (two-digit day) ]; /// diff --git a/src/TurboHTTP/Protocol/Semantics/PseudoHeaderValidator.cs b/src/TurboHTTP/Protocol/Semantics/PseudoHeaderValidator.cs index b09b123a7..8d5731a42 100644 --- a/src/TurboHTTP/Protocol/Semantics/PseudoHeaderValidator.cs +++ b/src/TurboHTTP/Protocol/Semantics/PseudoHeaderValidator.cs @@ -31,6 +31,7 @@ internal static void ValidateRequestPseudoHeaders( var lastPseudoIndex = -1; var firstRegularIndex = int.MaxValue; string? methodValue = null; + string? pathValue = null; for (var i = 0; i < headers.Count; i++) { @@ -69,6 +70,10 @@ internal static void ValidateRequestPseudoHeaders( { methodValue = getValue(headers[i]); } + else if (flag == PseudoFlags.Path) + { + pathValue = getValue(headers[i]); + } break; } @@ -97,6 +102,12 @@ internal static void ValidateRequestPseudoHeaders( throw new HttpProtocolException( $"{rfcSection}: Missing required pseudo-headers: {FormatMissing(missing)}"); } + + if (pathValue is not null && pathValue.Length == 0) + { + throw new HttpProtocolException( + string.Concat(rfcSection, ": :path pseudo-header MUST NOT be empty for non-CONNECT requests")); + } } private static void ValidateConnectRequest(PseudoFlags seen, string rfcSection) diff --git a/src/TurboHTTP/Protocol/Semantics/ReasonPhrases.cs b/src/TurboHTTP/Protocol/Semantics/ReasonPhrases.cs index bce608956..16d8366ad 100644 --- a/src/TurboHTTP/Protocol/Semantics/ReasonPhrases.cs +++ b/src/TurboHTTP/Protocol/Semantics/ReasonPhrases.cs @@ -36,7 +36,7 @@ internal static class ReasonPhrases [502] = "Bad Gateway", [503] = "Service Unavailable", [504] = "Gateway Timeout", - [505] = "HTTP Version Not Supported", + [505] = "HTTP Version Not Supported" }; public static string For(int code) => Table.GetValueOrDefault(code, ""); diff --git a/src/TurboHTTP/Protocol/Semantics/RedirectException.cs b/src/TurboHTTP/Protocol/Semantics/RedirectException.cs index 375b247d8..bd122d15d 100644 --- a/src/TurboHTTP/Protocol/Semantics/RedirectException.cs +++ b/src/TurboHTTP/Protocol/Semantics/RedirectException.cs @@ -20,7 +20,7 @@ internal enum RedirectError InvalidLocationHeader, /// The redirect would downgrade from HTTPS to HTTP and the policy forbids it. - ProtocolDowngrade, + ProtocolDowngrade } /// diff --git a/src/TurboHTTP/Protocol/Semantics/RedirectHandler.cs b/src/TurboHTTP/Protocol/Semantics/RedirectHandler.cs index e1b6bca6d..5c71bad1a 100644 --- a/src/TurboHTTP/Protocol/Semantics/RedirectHandler.cs +++ b/src/TurboHTTP/Protocol/Semantics/RedirectHandler.cs @@ -66,7 +66,6 @@ public HttpRequestMessage BuildRedirectRequest(HttpRequestMessage original, Http if (RedirectCount == 0) { var normalized = NormalizeUriForComparison(original.RequestUri); - System.Diagnostics.Debug.WriteLine($"[Redirect] Initial URI: {original.RequestUri} → normalized: {normalized}"); _visitedUris.Add(normalized); } @@ -79,7 +78,6 @@ public HttpRequestMessage BuildRedirectRequest(HttpRequestMessage original, Http } var locationUri = ResolveLocationUri(original.RequestUri, response); - System.Diagnostics.Debug.WriteLine($"[Redirect] Redirect #{RedirectCount + 1}: LocationUri={locationUri}"); // Detect HTTPS → HTTP downgrade if (!_policy.AllowHttpsToHttpDowngrade && @@ -95,7 +93,6 @@ public HttpRequestMessage BuildRedirectRequest(HttpRequestMessage original, Http // Detect redirect loops — normalized comparison is case-insensitive for // scheme/host and case-sensitive for path/query; fragments are ignored. var normalizedLocation = NormalizeUriForComparison(locationUri); - System.Diagnostics.Debug.WriteLine($"[Redirect] Normalized location: {normalizedLocation}, visited count: {_visitedUris.Count}, visited: {string.Join(", ", _visitedUris)}"); if (!_visitedUris.Add(normalizedLocation)) { throw new RedirectException( diff --git a/src/TurboHTTP/Protocol/Semantics/UriSanitizer.cs b/src/TurboHTTP/Protocol/Semantics/UriSanitizer.cs index 61670f786..2ac6deb00 100644 --- a/src/TurboHTTP/Protocol/Semantics/UriSanitizer.cs +++ b/src/TurboHTTP/Protocol/Semantics/UriSanitizer.cs @@ -48,7 +48,7 @@ public static string FormatAuthorityWithPort(Uri uri) { "https" => 443, "http" => 80, - _ => throw new ArgumentException($"Unknown scheme '{scheme}' — cannot determine default port.", nameof(scheme)), + _ => throw new ArgumentException($"Unknown scheme '{scheme}' — cannot determine default port.", nameof(scheme)) }; /// @@ -60,7 +60,7 @@ public static string StripUserInfo(Uri uri) var builder = new UriBuilder(uri) { UserName = string.Empty, - Password = string.Empty, + Password = string.Empty }; return builder.Uri.ToString(); @@ -76,7 +76,7 @@ public static string FormatAbsoluteWithoutUserInfo(Uri uri) { UserName = string.Empty, Password = string.Empty, - Fragment = string.Empty, + Fragment = string.Empty }; return builder.Uri.GetLeftPart(UriPartial.Query); diff --git a/src/TurboHTTP/Protocol/StreamIdKey.cs b/src/TurboHTTP/Protocol/StreamIdKey.cs deleted file mode 100644 index a9f9615e2..000000000 --- a/src/TurboHTTP/Protocol/StreamIdKey.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace TurboHTTP.Protocol; - -internal static class StreamIdKey -{ - public static readonly HttpRequestOptionsKey Http2 = new("TurboHTTP.StreamId.H2"); - public static readonly HttpRequestOptionsKey Http3 = new("TurboHTTP.StreamId.H3"); -} diff --git a/src/TurboHTTP/Protocol/Syntax/DecodeOutcome.cs b/src/TurboHTTP/Protocol/Syntax/DecodeOutcome.cs index 5645176b1..25626c66e 100644 --- a/src/TurboHTTP/Protocol/Syntax/DecodeOutcome.cs +++ b/src/TurboHTTP/Protocol/Syntax/DecodeOutcome.cs @@ -4,5 +4,5 @@ internal enum DecodeOutcome { NeedMore, HeadersReady, - Complete, + Complete } diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientDecoder.cs index 9feca94b3..5f3a12ff2 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientDecoder.cs @@ -1,12 +1,12 @@ using System.Net; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.LineBased; -using TurboHTTP.Protocol.LineBased.Body; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http10.Options; namespace TurboHTTP.Protocol.Syntax.Http10.Client; -internal sealed class Http10ClientDecoder +internal sealed class Http10ClientDecoder(Http10ClientDecoderOptions options) { private enum Phase { @@ -16,45 +16,47 @@ private enum Phase Done } - private readonly Http10ClientDecoderOptions _options; - private readonly HeaderBlockReader _headerReader; + private readonly HeaderBlockReader _headerReader = new( + options.MaxHeaderBytes, options.MaxHeaderCount, options.HeaderLineMaxLength, options.AllowObsFold); private Phase _phase = Phase.StatusLine; private Version _version = null!; private int _statusCode; private string _reason = null!; - private IBodyDecoder? _bodyDecoder; + private IBodyReader? _bodyReader; + private IFramingDecoder? _framingDecoder; + private IStreamingBodyReader? _streamingReader; private HttpResponseMessage? _response; private bool _isHttp09; - public Http10ClientDecoder(Http10ClientDecoderOptions options) - { - options.Validate(); - _options = options; - var s = options.Shared; - _headerReader = - new HeaderBlockReader(s.MaxHeaderBytes, s.MaxHeaderCount, s.HeaderLineMaxLength, s.AllowObsFold); - } + public bool IsBodyStreaming => _phase == Phase.Body && !_isHttp09 && _streamingReader is not null; + + public bool IsQueueFull => _streamingReader?.IsFull ?? false; - public DecodeOutcome Feed(ReadOnlySpan data, bool requestMethodWasHead, out int consumed) + public IStreamingBodyReader? StreamingReader => _streamingReader; + + public DecodeOutcome Feed(ReadOnlyMemory data, bool requestMethodWasHead, out int consumed) { consumed = 0; var pos = 0; + var span = data.Span; if (_phase == Phase.StatusLine) { - if (data.Length > 0 && !IsLikelyHttpResponse(data)) + if (span.Length > 0 && !IsLikelyHttpResponse(span)) { _isHttp09 = true; _version = HttpVersion.Version10; _statusCode = 200; _reason = "OK"; - _bodyDecoder = new CloseDelimitedBodyDecoder(); + var buffered = new BufferedBodyReader(); + buffered.ResetOpenEnded(); + _bodyReader = buffered; _phase = Phase.Body; } else { - if (!StatusLineParser.TryParse(data, out var ver, out var code, out var reason, out var slConsumed)) + if (!StatusLineParser.TryParse(span, out var ver, out var code, out var reason, out var slConsumed)) { return DecodeOutcome.NeedMore; } @@ -69,7 +71,7 @@ public DecodeOutcome Feed(ReadOnlySpan data, bool requestMethodWasHead, ou if (_phase == Phase.Headers) { - var result = _headerReader.Feed(data[pos..], out var hConsumed); + var result = _headerReader.Feed(span[pos..], out var hConsumed); pos += hConsumed; if (result == HeaderBlockResult.NeedMore) { @@ -82,36 +84,120 @@ public DecodeOutcome Feed(ReadOnlySpan data, bool requestMethodWasHead, ou _statusCode, headers, _version, requestMethodWasHead, connectionWillClose: !ConnectionSemantics.IsPersistent(headers, _version)); - _bodyDecoder = BodyDecoderFactory.Create( - classification, - _options.Shared.StreamingThreshold, - _options.Shared.BufferPool, - _options.Shared.MaxBufferedBodySize, - _options.Shared.MaxStreamedBodySize); + if (classification.Framing == BodyFraming.Close) + { + var buffered = new BufferedBodyReader(); + buffered.ResetOpenEnded(); + _bodyReader = buffered; + _framingDecoder = null; + } + else + { + var (reader, decoder) = BodyReaderFactory.Create(classification, options.ToBodyDecoderOptions()); + _bodyReader = reader; + _framingDecoder = decoder; + if (reader is IStreamingBodyReader streaming) + { + _streamingReader = streaming; + } + } _phase = Phase.Body; } if (_phase == Phase.Body) { - var slice = data[pos..]; - var done = _bodyDecoder!.Feed(slice, out var bConsumed); - pos += bConsumed; - consumed = pos; - if (done) + if (_bodyReader is BufferedBodyReader buffered) { - _phase = Phase.Done; - return DecodeOutcome.Complete; + var take = buffered.Feed(span[pos..]); + pos += take; + consumed = pos; + if (buffered.IsCompleted) + { + _phase = Phase.Done; + return DecodeOutcome.Complete; + } + + return _isHttp09 ? DecodeOutcome.HeadersReady : DecodeOutcome.NeedMore; + } + + if (_streamingReader is not null && _framingDecoder is not null) + { + var remaining = span[pos..]; + while (remaining.Length > 0) + { + var result = _framingDecoder.Decode(remaining, out var rawConsumed); + pos += rawConsumed; + + if (!result.Body.IsEmpty) + { + _streamingReader.TryEnqueue(result.Body); + } + + if (result.EndOfBody) + { + _streamingReader.Complete(); + _phase = Phase.Done; + consumed = pos; + return DecodeOutcome.Complete; + } + + if (rawConsumed == 0) + { + break; + } + + remaining = span[pos..]; + } + + consumed = pos; + return _isHttp09 ? DecodeOutcome.HeadersReady : DecodeOutcome.NeedMore; } - return _isHttp09 ? DecodeOutcome.HeadersReady : DecodeOutcome.NeedMore; + consumed = pos; + return DecodeOutcome.Complete; } consumed = pos; return DecodeOutcome.Complete; } - public bool SignalEof() => _bodyDecoder?.OnEof() ?? false; + public bool SignalEof() + { + if (_streamingReader is not null && _framingDecoder is not null) + { + var ok = _framingDecoder.OnEof(); + if (ok) + { + _streamingReader.Complete(); + } + else + { + _streamingReader.Fault(new HttpRequestException( + "Connection closed before the complete response body was received.")); + } + + return ok; + } + + if (_framingDecoder is not null) + { + return _framingDecoder.OnEof(); + } + + if (_bodyReader is BufferedBodyReader buffered && !buffered.IsCompleted) + { + if (buffered.IsOpenEnded) + { + buffered.MarkComplete(); + return true; + } + + return false; + } + + return false; + } public HttpResponseMessage GetResponse() { @@ -121,7 +207,7 @@ public HttpResponseMessage GetResponse() } HttpContent content; - var bodyStream = _bodyDecoder?.GetBodyStream(); + var bodyStream = _bodyReader?.AsStream(); if (bodyStream is not null) { content = new StreamContent(bodyStream); @@ -135,7 +221,7 @@ public HttpResponseMessage GetResponse() { Version = _version, ReasonPhrase = _reason, - Content = content, + Content = content }; HeaderRouter.ApplyToResponse(msg, _headerReader.GetHeaders()); _response = msg; @@ -148,7 +234,9 @@ public void Reset() _version = null!; _statusCode = 0; _reason = null!; - _bodyDecoder = null; + _bodyReader = null; + _framingDecoder = null; + _streamingReader = null; _response = null; _isHttp09 = false; _headerReader.Reset(); @@ -163,4 +251,4 @@ private static bool IsLikelyHttpResponse(ReadOnlySpan data) return WellKnownHeaders.Http.Bytes.Span[..data.Length].SequenceEqual(data); } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientEncoder.cs index 50fcf2b40..7f733c9c1 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientEncoder.cs @@ -1,33 +1,21 @@ using System.Globalization; using System.Net; -using Akka.Actor; using TurboHTTP.Protocol.LineBased; -using TurboHTTP.Protocol.LineBased.Body; -using TurboHTTP.Protocol.Syntax.Http10.Options; namespace TurboHTTP.Protocol.Syntax.Http10.Client; internal sealed class Http10ClientEncoder { - private readonly Http10ClientEncoderOptions _options; - - public Http10ClientEncoder(Http10ClientEncoderOptions options) - { - options.Validate(); - _options = options; - } - - public int Encode(Span destination, HttpRequestMessage request, IActorRef stageActor) + public int Encode(Span destination, HttpRequestMessage request, out Stream? bodyStream) { if (request.Content is null) { + bodyStream = null; return EncodeHeadersOnly(destination, request, contentLength: 0); } // HTTP/1.0 always defers — need body bytes before Content-Length header can be written - var bodyEncoder = new ContentLengthBufferedBodyEncoder(); - var bodyStream = request.Content.ReadAsStream(); - bodyEncoder.Start(bodyStream, stageActor); + bodyStream = request.Content.ReadAsStream(); return 0; } @@ -59,4 +47,4 @@ private int EncodeHeadersOnly(Span destination, HttpRequestMessage request HeaderBlockWriter.Write(ref writer, headers); return writer.BytesWritten; } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientStateMachine.cs index 259d7a4c7..8cd35614e 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Client/Http10ClientStateMachine.cs @@ -1,10 +1,11 @@ using System.Buffers; +using Akka.Actor; using Servus.Akka.Transport; using TurboHTTP.Client; using TurboHTTP.Internal; -using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Protocol.Body; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http10.Client; @@ -22,16 +23,24 @@ internal sealed class Http10ClientStateMachine : IClientStateMachine private bool _lastRequestWasHead; private bool _outboundBodyPending; private HttpRequestMessage? _deferredRequest; - private IMemoryOwner? _deferredBodyOwner; - private int _deferredBodyLength; + private IBodyWriter? _currentBodyWriter; + private Stream? _currentBodyStream; + private IStreamingBodyReader? _activeStreamingReader; private bool _connectionClosed; + internal sealed record BodyReadComplete(int BytesRead); + internal sealed record BodyReadFailed(Exception Reason); + internal sealed record BodyBufferComplete(IMemoryOwner Owner, int Written); + internal sealed record StreamingSlotFreed; + public bool CanAcceptRequest => _inFlightRequest is null && !IsReconnecting && !_outboundBodyPending; public bool HasInFlightRequests => _inFlightRequest is not null; public bool IsReconnecting { get; private set; } + public bool ShouldPauseNetwork => _activeStreamingReader?.IsFull ?? false; + private int PendingRequestCount { get @@ -52,19 +61,10 @@ public Http10ClientStateMachine(IClientStageOperations ops, TurboClientOptions o _ops = ops; _options = options; - var decoderOpts = new Http10ClientDecoderOptions - { - Shared = SharedHttpOptions.Default with - { - MaxHeaderBytes = options.Http1.MaxResponseHeadersLength * 1024, - MaxBufferedBodySize = options.MaxBufferedBodySize, - MaxStreamedBodySize = options.MaxStreamedBodySize, - } - }; - var encoderOpts = Http10ClientEncoderOptions.Default; + var decoderOpts = options.ToHttp10DecoderOptions(); _decoder = new Http10ClientDecoder(decoderOpts); - _encoder = new Http10ClientEncoder(encoderOpts); + _encoder = new Http10ClientEncoder(); } public void PreStart() @@ -76,6 +76,17 @@ public void OnRequest(HttpRequestMessage request) EncodeRequest(request); } + public void OnRequestCancelled(HttpRequestMessage request) + { + if (_inFlightRequest is not null && ReferenceEquals(_inFlightRequest, request)) + { + request.Fail(new OperationCanceledException("Request cancelled by caller.")); + _inFlightRequest = null; + _ops.OnOutbound(new DisconnectTransport(DisconnectReason.Error)); + Tracing.For("Protocol").Debug(this, "HTTP/1.0: cancelled request, disconnecting"); + } + } + public void DecodeServerData(ITransportInbound data) { switch (data) @@ -131,50 +142,50 @@ public void OnBodyMessage(object msg) { switch (msg) { - case OutboundBodyChunk chunk when _deferredRequest is not null: - _deferredBodyOwner?.Dispose(); - _deferredBodyOwner = chunk.Owner; - _deferredBodyLength = chunk.Length; + case StreamingSlotFreed: break; - case OutboundBodyComplete when _deferredRequest is not null && _deferredBodyOwner is not null: + case BodyReadComplete read: + HandleBodyRead(read.BytesRead); + break; + + case BodyReadFailed failed: + Tracing.For("Protocol").Warning(this, "request body failed: {0}", failed.Reason.Message); + _currentBodyWriter?.Dispose(); + _currentBodyWriter = null; + _currentBodyStream = null; + _outboundBodyPending = false; + if (_deferredRequest is not null) + { + _deferredRequest.Fail(new HttpRequestException("Failed to read HTTP/1.0 request body.", + failed.Reason)); + _deferredRequest = null; + } + + break; + + case BodyBufferComplete bufferDone: TransportBuffer? item = null; try { - var body = _deferredBodyOwner.Memory.Span[.._deferredBodyLength]; - item = TransportBuffer.Rent(HttpMessageSize.Estimate(_deferredRequest, _deferredBodyLength)); - var written = _encoder.EncodeDeferred(item.FullMemory.Span, _deferredRequest, body); + var body = bufferDone.Owner.Memory.Span[..bufferDone.Written]; + item = TransportBuffer.Rent(HttpMessageSize.Estimate(_deferredRequest!, bufferDone.Written)); + var written = _encoder.EncodeDeferred(item.FullMemory.Span, _deferredRequest!, body); item.Length = written; - _ops.OnOutbound(new TransportData(item)); + _ops.OnOutbound(TransportData.Rent(item)); } catch (Exception ex) { item?.Dispose(); - _deferredRequest.Fail(new HttpRequestException("Failed to encode HTTP/1.0 request body.", ex)); + _deferredRequest!.Fail(new HttpRequestException("Failed to encode HTTP/1.0 request body.", ex)); } finally { - _deferredBodyOwner.Dispose(); - _deferredBodyOwner = null; + bufferDone.Owner.Dispose(); _deferredRequest = null; _outboundBodyPending = false; - } - - break; - - case OutboundBodyComplete: - _outboundBodyPending = false; - break; - - case OutboundBodyFailed failed: - _deferredBodyOwner?.Dispose(); - _deferredBodyOwner = null; - _outboundBodyPending = false; - if (_deferredRequest is not null) - { - _deferredRequest.Fail(new HttpRequestException("Failed to read HTTP/1.0 request body.", - failed.Reason)); - _deferredRequest = null; + _currentBodyWriter = null; + _currentBodyStream = null; } break; @@ -185,8 +196,10 @@ public void Cleanup() { _inFlightRequest = null; _outboundBodyPending = false; - _deferredBodyOwner?.Dispose(); - _deferredBodyOwner = null; + _activeStreamingReader = null; + _currentBodyWriter?.Dispose(); + _currentBodyWriter = null; + _currentBodyStream = null; _deferredRequest = null; _connectionClosed = false; _decoder.Reset(); @@ -218,19 +231,24 @@ private void EncodeRequest(HttpRequestMessage request) item = TransportBuffer.Rent(HttpMessageSize.Estimate(request, contentLength)); var span = item.FullMemory.Span; - var written = _encoder.Encode(span, request, _ops.StageActor); + var written = _encoder.Encode(span, request, out var bodyStream); if (written > 0) { item.Length = written; - _ops.OnOutbound(new TransportData(item)); + _ops.OnOutbound(TransportData.Rent(item)); } - else + else if (bodyStream is not null) { - // Deferred — HTTP/1.0 with body; waiting for OutboundBodyChunk + OutboundBodyComplete item.Dispose(); item = null; _deferredRequest = request; _outboundBodyPending = true; + StartBodyBuffer(bodyStream); + } + else + { + item.Dispose(); + item = null; } } catch (Exception ex) @@ -243,12 +261,45 @@ private void EncodeRequest(HttpRequestMessage request) } } + private void StartBodyBuffer(Stream bodyStream) + { + _currentBodyWriter = new BufferedBodyWriter(); + ((BufferedBodyWriter)_currentBodyWriter).Reset(onComplete: (owner, written) => + { + _ops.StageActor.Tell(new BodyBufferComplete(owner, written), ActorRefs.NoSender); + }); + _currentBodyStream = bodyStream; + ReadNextChunk(); + } + + private void ReadNextChunk() + { + var mem = _currentBodyWriter!.GetMemory(_options.RequestBodyChunkSize); + _currentBodyStream!.ReadAsync(mem).PipeTo( + _ops.StageActor, + success: bytesRead => new BodyReadComplete(bytesRead), + failure: ex => new BodyReadFailed(ex)); + } + + private void HandleBodyRead(int bytesRead) + { + if (bytesRead > 0) + { + _currentBodyWriter!.Advance(bytesRead); + _currentBodyWriter.FlushAsync(); + ReadNextChunk(); + } + else + { + _currentBodyWriter!.CompleteAsync(); + } + } + private void DecodeResponse(TransportBuffer buffer) { try { - var outcome = _decoder.Feed(buffer.Memory.Span, _lastRequestWasHead, out _); - buffer.Dispose(); + var outcome = _decoder.Feed(buffer.Memory, _lastRequestWasHead, out _); if (outcome == DecodeOutcome.Complete) { @@ -256,10 +307,26 @@ private void DecodeResponse(TransportBuffer buffer) CompleteResponse(response); _decoder.Reset(); } + else if (_decoder.IsBodyStreaming) + { + var response = _decoder.GetResponse(); + if (_inFlightRequest is not null) + { + response.RequestMessage = _inFlightRequest; + } + + _ops.OnResponse(response); + + if (_decoder.StreamingReader is { } sr) + { + _activeStreamingReader = sr; + sr.SlotFreed += () => + _ops.StageActor.Tell(new StreamingSlotFreed(), ActorRefs.NoSender); + } + } } catch (Exception ex) { - buffer.Dispose(); Tracing.For("Protocol").Error(this, "Failed to decode HTTP/1.0 response: {0}", ex.Message); if (_inFlightRequest is { } req) { @@ -267,8 +334,13 @@ private void DecodeResponse(TransportBuffer buffer) _inFlightRequest = null; } + _activeStreamingReader = null; _decoder.Reset(); } + finally + { + buffer.Dispose(); + } } private void HandleDisconnect(TransportDisconnected disconnect) @@ -407,4 +479,4 @@ private void CompleteResponse(HttpResponseMessage response) _ops.OnResponse(response); } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ClientDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ClientDecoderOptions.cs index 7c7cda171..6c688dd82 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ClientDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ClientDecoderOptions.cs @@ -2,17 +2,11 @@ namespace TurboHTTP.Protocol.Syntax.Http10.Options; internal sealed record Http10ClientDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - - public static Http10ClientDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Http10ClientDecoderOptions.Shared must not be null."); - } - - Shared.Validate(); - } + public required long StreamingThreshold { get; init; } + public required long MaxBufferedBodySize { get; init; } + public required long? MaxStreamedBodySize { get; init; } + public required int MaxHeaderBytes { get; init; } + public required int MaxHeaderCount { get; init; } + public required int HeaderLineMaxLength { get; init; } + public required bool AllowObsFold { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ClientEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ClientEncoderOptions.cs deleted file mode 100644 index a994a544e..000000000 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ClientEncoderOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace TurboHTTP.Protocol.Syntax.Http10.Options; - -internal sealed record Http10ClientEncoderOptions -{ - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - - public static Http10ClientEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Http10ClientEncoderOptions.Shared must not be null."); - } - - Shared.Validate(); - } -} diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerDecoderOptions.cs index 3a81ca328..a6e5fb055 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerDecoderOptions.cs @@ -2,17 +2,13 @@ namespace TurboHTTP.Protocol.Syntax.Http10.Options; internal sealed record Http10ServerDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - - public static Http10ServerDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Http10ServerDecoderOptions.Shared must not be null."); - } - - Shared.Validate(); - } + public required long StreamingThreshold { get; init; } + public required long MaxBufferedBodySize { get; init; } + public required long? MaxStreamedBodySize { get; init; } + public required int MaxHeaderBytes { get; init; } + public required int MaxHeaderCount { get; init; } + public required int HeaderLineMaxLength { get; init; } + public required int RequestLineMaxLength { get; init; } + public required int MaxRequestTargetLength { get; init; } + public required bool AllowObsFold { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerEncoderOptions.cs index 4e7a07902..3e8c233f0 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerEncoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Options/Http10ServerEncoderOptions.cs @@ -2,18 +2,6 @@ namespace TurboHTTP.Protocol.Syntax.Http10.Options; internal sealed record Http10ServerEncoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public bool WriteDateHeader { get; init; } = true; - - public static Http10ServerEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Http10ServerEncoderOptions.Shared must not be null."); - } - - Shared.Validate(); - } + public required bool WriteDateHeader { get; init; } + public required int MaxHeaderBytes { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerDecoder.cs index 3b022f725..b602688af 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerDecoder.cs @@ -1,13 +1,12 @@ -using Microsoft.AspNetCore.Http; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.LineBased; -using TurboHTTP.Protocol.LineBased.Body; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http10.Options; using TurboHTTP.Server.Context.Features; namespace TurboHTTP.Protocol.Syntax.Http10.Server; -internal sealed class Http10ServerDecoder +internal sealed class Http10ServerDecoder(Http10ServerDecoderOptions options) { private enum Phase { @@ -17,35 +16,39 @@ private enum Phase Done } - private readonly Http10ServerDecoderOptions _options; - private readonly HeaderBlockReader _headerReader; + private readonly HeaderBlockReader _headerReader = new(options.MaxHeaderBytes, options.MaxHeaderCount, options.HeaderLineMaxLength, options.AllowObsFold); private Phase _phase = Phase.RequestLine; private HttpMethod _method = null!; private string _target = null!; private Version _version = null!; - private IBodyDecoder? _bodyDecoder; - public Http10ServerDecoder(Http10ServerDecoderOptions options) - { - options.Validate(); - _options = options; - var s = options.Shared; - _headerReader = new HeaderBlockReader(s.MaxHeaderBytes, s.MaxHeaderCount, s.HeaderLineMaxLength, s.AllowObsFold); - } + public IBodyReader? CurrentBodyReader { get; private set; } + public IFramingDecoder? CurrentFramingDecoder { get; private set; } + public IStreamingBodyReader? StreamingReader { get; private set; } + public bool IsQueueFull => StreamingReader?.IsFull ?? false; + + public int LastBodyBytesConsumed { get; private set; } - public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) + public DecodeOutcome Feed(ReadOnlyMemory data, out int consumed) { consumed = 0; var pos = 0; + var span = data.Span; if (_phase == Phase.RequestLine) { - if (!RequestLineParser.TryParse(data, _options.Shared.RequestLineMaxLength, out var method, out var target, out var version, out var rlConsumed)) + if (!RequestLineParser.TryParse(span, options.RequestLineMaxLength, out var method, out var target, out var version, out var rlConsumed)) { return DecodeOutcome.NeedMore; } + if (target.Length > options.MaxRequestTargetLength) + { + throw new HttpProtocolException( + $"Request target length {target.Length} exceeds limit ({options.MaxRequestTargetLength})."); + } + _method = method; _target = target; _version = version; @@ -55,7 +58,7 @@ public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) if (_phase == Phase.Headers) { - var result = _headerReader.Feed(data[pos..], out var hConsumed); + var result = _headerReader.Feed(span[pos..], out var hConsumed); pos += hConsumed; if (result == HeaderBlockResult.NeedMore) { @@ -64,27 +67,100 @@ public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) } var classification = BodySemantics.ClassifyRequest(_method, _headerReader.GetHeaders(), _version); - _bodyDecoder = BodyDecoderFactory.Create( - classification, - _options.Shared.StreamingThreshold, - _options.Shared.BufferPool, - _options.Shared.MaxBufferedBodySize, - _options.Shared.MaxStreamedBodySize); + var (reader, decoder) = BodyReaderFactory.Create(classification, options.ToBodyDecoderOptions()); + CurrentBodyReader = reader; + CurrentFramingDecoder = decoder; + if (reader is IStreamingBodyReader streaming) + { + StreamingReader = streaming; + } + + if (CurrentBodyReader is null || (CurrentBodyReader is BufferedBodyReader { IsCompleted: true })) + { + _phase = Phase.Done; + consumed = pos; + return DecodeOutcome.Complete; + } + _phase = Phase.Body; + + if (CurrentBodyReader is not BufferedBodyReader) + { + consumed = pos; + return DecodeOutcome.HeadersReady; + } } if (_phase == Phase.Body) { - var done = _bodyDecoder!.Feed(data[pos..], out var bConsumed); - pos += bConsumed; - consumed = pos; - if (done) + if (CurrentBodyReader is BufferedBodyReader bufferedBody) { - _phase = Phase.Done; - return DecodeOutcome.Complete; + var take = bufferedBody.Feed(span[pos..]); + LastBodyBytesConsumed = take; + pos += take; + consumed = pos; + if (bufferedBody.IsCompleted) + { + _phase = Phase.Done; + return DecodeOutcome.Complete; + } + + return DecodeOutcome.NeedMore; } - return DecodeOutcome.NeedMore; + if (StreamingReader is not null && CurrentFramingDecoder is not null) + { + var remaining = span[pos..]; + var bodyConsumed = 0; + while (remaining.Length > 0) + { + var result = CurrentFramingDecoder.Decode(remaining, out var rawConsumed); + pos += rawConsumed; + bodyConsumed += rawConsumed; + + if (!result.Body.IsEmpty) + { + if (!StreamingReader.TryEnqueue(result.Body)) + { + if (result.EndOfBody) + { + StreamingReader.Complete(); + _phase = Phase.Done; + LastBodyBytesConsumed = bodyConsumed; + consumed = pos; + return DecodeOutcome.Complete; + } + + LastBodyBytesConsumed = bodyConsumed; + consumed = pos; + return DecodeOutcome.NeedMore; + } + } + + if (result.EndOfBody) + { + StreamingReader.Complete(); + _phase = Phase.Done; + LastBodyBytesConsumed = bodyConsumed; + consumed = pos; + return DecodeOutcome.Complete; + } + + if (rawConsumed == 0) + { + break; + } + + remaining = span[pos..]; + } + + LastBodyBytesConsumed = bodyConsumed; + consumed = pos; + return DecodeOutcome.NeedMore; + } + + consumed = pos; + return DecodeOutcome.Complete; } consumed = pos; @@ -93,25 +169,26 @@ public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) public TurboHttpRequestFeature GetRequestFeature() { - var headers = new HeaderDictionary(); - HeaderRouter.ApplyToHeaderDictionary(headers, _headerReader.GetHeaders()); - var body = _bodyDecoder?.GetBodyStream() ?? Stream.Null; + var body = CurrentBodyReader?.AsStream() ?? Stream.Null; - return new TurboHttpRequestFeature + var feature = new TurboHttpRequestFeature { Protocol = _version switch { - { Major: 1, Minor: 0 } => "HTTP/1.0", - { Major: 1, Minor: 1 } => "HTTP/1.1", - _ => "HTTP/1.1" + { Major: 1, Minor: 0 } => WellKnownHeaders.Http10, + _ => WellKnownHeaders.Http11 }, Method = _method.Method, Path = ParsePath(_target), QueryString = ParseQueryString(_target), RawTarget = _target, - Headers = headers, - Body = body, + Body = body }; + + // Populate directly into the feature's header dictionary, avoiding a throwaway + // HeaderDictionary allocation plus the copy loop in the Headers setter. + HeaderRouter.ApplyToHeaderDictionary(feature.Headers, _headerReader.GetHeaders()); + return feature; } private static string ParsePath(string target) @@ -126,4 +203,17 @@ private static string ParseQueryString(string target) var queryIdx = target.IndexOf('?'); return queryIdx >= 0 ? target[queryIdx..] : string.Empty; } -} \ No newline at end of file + + public void Reset() + { + _phase = Phase.RequestLine; + _method = null!; + _target = null!; + _version = null!; + CurrentBodyReader = null; + CurrentFramingDecoder = null; + StreamingReader = null; + LastBodyBytesConsumed = 0; + _headerReader.Reset(); + } +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerEncoder.cs index 7a3eebc8b..3f44bd7b0 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerEncoder.cs @@ -7,17 +7,10 @@ namespace TurboHTTP.Protocol.Syntax.Http10.Server; -internal sealed class Http10ServerEncoder +internal sealed class Http10ServerEncoder(Http10ServerEncoderOptions options) { - private readonly Http10ServerEncoderOptions _options; private readonly HeaderCollection _reusableHeaders = new(); - public Http10ServerEncoder(Http10ServerEncoderOptions options) - { - options.Validate(); - _options = options; - } - public int Encode(Span _, IFeatureCollection features, IActorRef stageActor) { // HTTP/1.0 always defers — body sink will be handled by caller @@ -32,7 +25,6 @@ public int EncodeDeferred(Span destination, IFeatureCollection features, R StatusLineWriter.Write(ref writer, HttpVersion.Version10, statusCode); _reusableHeaders.Clear(); - var headers = _reusableHeaders; var responseHeaders = responseFeature?.Headers; if (responseHeaders is not null) { @@ -47,20 +39,20 @@ public int EncodeDeferred(Span destination, IFeatureCollection features, R { if (v is not null) { - headers.Add(h.Key, v); + _reusableHeaders.Add(h.Key, v); } } } } - headers.Add(WellKnownHeaders.ContentLength, ContentLengthCache.GetValue(body.Length)); + _reusableHeaders.Add(WellKnownHeaders.ContentLength, ContentLengthCache.GetValue(body.Length)); - if (_options.WriteDateHeader && !headers.Contains(WellKnownHeaders.Date)) + if (options.WriteDateHeader && !_reusableHeaders.Contains(WellKnownHeaders.Date)) { - headers.Add(WellKnownHeaders.Date, DateHeaderCache.GetValue()); + _reusableHeaders.Add(WellKnownHeaders.Date, DateHeaderCache.GetValue()); } - HeaderBlockWriter.Write(ref writer, headers); + HeaderBlockWriter.Write(ref writer, _reusableHeaders); if (body.Length > 0) { diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs index d9a617e5d..9d5b5bad3 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs @@ -1,56 +1,61 @@ using System.Buffers; +using Akka.Actor; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol.LineBased.Body; -using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Protocol.Body; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Streams.Stages.Server; -using static Servus.Core.Servus; -using HttpVersion = System.Net.HttpVersion; - +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http10.Server; +internal readonly record struct ResponseBodyReadComplete(int BytesRead); + +internal readonly record struct ResponseBodyReadFailed(Exception Reason); + +internal readonly record struct ResponseBodyBuffered(IMemoryOwner Owner, int Written); + internal sealed class Http10ServerStateMachine : IServerStateMachine { + private const string DataRateCheck = "data-rate-check"; + private readonly IServerStageOperations _ops; private readonly Http10ServerDecoder _decoder; private readonly Http10ServerEncoder _encoder; private readonly long _maxRequestBodySize; - private readonly TurboServerOptions _serverOptions; + private readonly DataRateMonitor _requestRate; + private readonly DataRateMonitor _responseRate; + private readonly TimeProvider _clock; + + private long Now() => _clock.GetUtcNow().ToUnixTimeMilliseconds(); private IFeatureCollection? _deferredFeatures; - private IMemoryOwner? _deferredBodyOwner; - private int _deferredBodyLength; - private IBodyEncoder? _activeBodyEncoder; - private bool _errorOccurred; + private BufferedBodyWriter? _activeBodyWriter; + private Stream? _activeBodyStream; + private bool _bodyStreaming; + private IStreamingBodyReader? _activeStreamingReader; public bool CanAcceptResponse => true; - public bool ShouldComplete => _errorOccurred; + public bool ShouldComplete { get; private set; } + public bool ShouldPauseNetwork => _activeStreamingReader?.IsFull ?? false; + public int MaxQueuedRequests => 1; - public Http10ServerStateMachine(TurboServerOptions options, IServerStageOperations ops) + public Http10ServerStateMachine(Http1ConnectionOptions options, IServerStageOperations ops, + TimeProvider? timeProvider = null) { _ops = ops ?? throw new ArgumentNullException(nameof(ops)); ArgumentNullException.ThrowIfNull(options); - _serverOptions = options; - _maxRequestBodySize = options.Http1.MaxRequestBodySize; + _maxRequestBodySize = options.Limits.MaxRequestBodySize; + _clock = timeProvider ?? TimeProvider.System; - var shared = SharedHttpOptions.Default with - { - MaxBufferedBodySize = options.BodyBufferThreshold, - MaxStreamedBodySize = options.Http1.MaxRequestBodySize, - MaxHeaderBytes = options.Http1.MaxHeaderListSize, - HeaderLineMaxLength = options.Http1.MaxRequestLineLength, - RequestLineMaxLength = options.Http1.MaxRequestLineLength, - }; - - var decoderOpts = new Http10ServerDecoderOptions { Shared = shared }; - var encoderOpts = new Http10ServerEncoderOptions { Shared = shared }; - - _decoder = new Http10ServerDecoder(decoderOpts); - _encoder = new Http10ServerEncoder(encoderOpts); + var rate = options.ToRateMonitor(); + _requestRate = new DataRateMonitor(rate.MinRequestBodyDataRate, rate.MinRequestBodyDataRateGracePeriod); + _responseRate = new DataRateMonitor(rate.MinResponseDataRate, rate.MinResponseDataRateGracePeriod); + + _decoder = new Http10ServerDecoder(options.ToHttp10DecoderOptions()); + _encoder = new Http10ServerEncoder(options.ToHttp10EncoderOptions()); } public void PreStart() @@ -59,7 +64,6 @@ public void PreStart() public void DecodeClientData(ITransportInbound data) { - if (data is not TransportData { Buffer: var buffer }) { return; @@ -72,20 +76,86 @@ public void DecodeClientData(ITransportInbound data) return; } - var outcome = _decoder.Feed(buffer.Memory.Span, out _); + var pos = 0; + + if (_bodyStreaming && _decoder.StreamingReader is not null) + { + var outcome = _decoder.Feed(buffer.Memory[pos..], out var bodyConsumed); + pos += bodyConsumed; + if (_decoder.LastBodyBytesConsumed > 0) + { + _requestRate.Observe(0, _decoder.LastBodyBytesConsumed, Now()); + EnsureRateTimer(); + } + + if (outcome == DecodeOutcome.Complete) + { + _bodyStreaming = false; + _activeStreamingReader = null; + _requestRate.Remove(0); + } + + return; + } + + var result = _decoder.Feed(buffer.Memory[pos..], out var consumed); + pos += consumed; + if (_decoder.LastBodyBytesConsumed > 0) + { + _requestRate.Observe(0, _decoder.LastBodyBytesConsumed, Now()); + EnsureRateTimer(); + } - if (outcome == DecodeOutcome.Complete) + if (result == DecodeOutcome.Complete || result == DecodeOutcome.HeadersReady) { var feature = _decoder.GetRequestFeature(); - var hasBody = feature.Body != Stream.Null; - var features = FeatureCollectionFactory.Create(feature, hasBody, _ops.Services, _ops.ConnectionFeature, _ops.TlsHandshakeFeature, _serverOptions.Limits.MaxRequestBodySize); + var hasBody = result == DecodeOutcome.HeadersReady || feature.Body != Stream.Null; + var features = FeatureCollectionFactory.Create(feature, hasBody, _ops.Services, _ops.ConnectionFeature, + _ops.TlsHandshakeFeature, _maxRequestBodySize); + + if (result != DecodeOutcome.HeadersReady) + { + _requestRate.Remove(0); + } + _ops.OnRequest(features); + + if (result == DecodeOutcome.HeadersReady) + { + _bodyStreaming = true; + + if (_decoder.StreamingReader is { } sr && _activeStreamingReader is null) + { + _activeStreamingReader = sr; + sr.SlotFreed += () => + _ops.StageActor.Tell(new BodyResumed(), ActorRefs.NoSender); + } + + if (pos < buffer.Memory.Length) + { + var bodyOutcome = _decoder.Feed(buffer.Memory[pos..], out var bodyConsumed); + pos += bodyConsumed; + if (_decoder.LastBodyBytesConsumed > 0) + { + _requestRate.Observe(0, _decoder.LastBodyBytesConsumed, Now()); + EnsureRateTimer(); + } + + if (bodyOutcome == DecodeOutcome.Complete) + { + _bodyStreaming = false; + _activeStreamingReader = null; + _requestRate.Remove(0); + } + } + } } } - catch (Exception) + catch (Exception ex) { - _errorOccurred = true; + Tracing.For("Protocol").Warning(this, "Failed to decode HTTP/1.0 request: {0}", ex.Message); + ShouldComplete = true; } finally { @@ -95,18 +165,31 @@ public void DecodeClientData(ITransportInbound data) public void OnResponse(IFeatureCollection features) { - _deferredFeatures = features; var responseBody = features.Get(); if (responseBody is TurboHttpResponseBodyFeature turboBody) { + if (turboBody.TryGetBufferedBody(out var bufferedBody)) + { + if (bufferedBody.Length > 0) + { + EncodeDeferredResponse(bufferedBody.Span); + } + + return; + } + var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, null, HttpVersion.Version10); - if (encoder is not null) + if (bodyStream is not null) { - _activeBodyEncoder = encoder; - encoder.Start(bodyStream!, _ops.StageActor); + _activeBodyWriter = new BufferedBodyWriter(); + _activeBodyWriter.Reset(onComplete: (owner, written) => + { + _ops.StageActor.Tell(new ResponseBodyBuffered(owner, written), ActorRefs.NoSender); + }); + _activeBodyStream = bodyStream; + ReadNextResponseChunk(); return; } } @@ -114,43 +197,93 @@ public void OnResponse(IFeatureCollection features) EncodeDeferredResponse(ReadOnlySpan.Empty); } + private void ReadNextResponseChunk() + { + var mem = _activeBodyWriter!.GetMemory(16 * 1024); + var vt = _activeBodyStream!.ReadAsync(mem); + if (vt.IsCompletedSuccessfully) + { + HandleResponseBodyRead(vt.Result); + return; + } + + vt.PipeTo( + _ops.StageActor, + success: bytesRead => new ResponseBodyReadComplete(bytesRead), + failure: ex => new ResponseBodyReadFailed(ex)); + } + + private void HandleResponseBodyRead(int bytesRead) + { + if (bytesRead > 0) + { + _responseRate.Observe(0, bytesRead, Now()); + EnsureRateTimer(); + if (_activeBodyWriter is not null) + { + _activeBodyWriter.Advance(bytesRead); + _activeBodyWriter.FlushAsync(); + ReadNextResponseChunk(); + } + } + else + { + _activeBodyWriter?.CompleteAsync(); + } + } + public void OnDownstreamFinished() { } public void OnTimerFired(string name) { + if (name == DataRateCheck) + { + var violations = new List(); + _requestRate.Check(Now(), violations); + _responseRate.Check(Now(), violations); + + if (violations.Count > 0) + { + Tracing.For("Protocol").Warning(this, + "data rate violation (reqRate={0}, respRate={1})", + _requestRate.Count, _responseRate.Count); + ShouldComplete = true; + return; + } + + if (_requestRate.Count > 0 || _responseRate.Count > 0) + { + _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); + } + } } public void OnBodyMessage(object msg) { - switch (msg) { - case OutboundBodyChunk chunk when _deferredFeatures is not null: - _deferredBodyOwner?.Dispose(); - _deferredBodyOwner = chunk.Owner; - _deferredBodyLength = chunk.Length; + case ResponseBodyReadComplete read: + HandleResponseBodyRead(read.BytesRead); break; - case OutboundBodyComplete when _deferredFeatures is not null: - var body = _deferredBodyOwner is not null - ? _deferredBodyOwner.Memory.Span[.._deferredBodyLength] - : ReadOnlySpan.Empty; + case ResponseBodyBuffered bufferDone: + var body = bufferDone.Owner.Memory.Span[..bufferDone.Written]; EncodeDeferredResponse(body); - _deferredBodyOwner?.Dispose(); - _deferredBodyOwner = null; + bufferDone.Owner.Dispose(); + _activeBodyWriter = null; + _activeBodyStream = null; + _responseRate.Remove(0); break; - case OutboundBodyFailed failed: - _deferredBodyOwner?.Dispose(); - _deferredBodyOwner = null; - if (_deferredFeatures is not null) - { - Tracing.For("Protocol").Error(this, "Failed to read HTTP/1.0 response body: {0}", failed.Reason.Message); - _deferredFeatures = null; - _errorOccurred = true; - } + case ResponseBodyReadFailed failed: + Tracing.For("Protocol").Warning(this, "response body failed: {0}", failed.Reason.Message); + _activeBodyWriter?.Dispose(); + _activeBodyWriter = null; + _activeBodyStream = null; + _responseRate.Remove(0); + ShouldComplete = true; break; } } @@ -165,12 +298,12 @@ private void EncodeDeferredResponse(ReadOnlySpan body) TransportBuffer? item = null; try { - var bufferSize = 8192 + body.Length; + var bufferSize = 8 * 1024 + body.Length; item = TransportBuffer.Rent(bufferSize); var written = _encoder.EncodeDeferred(item.FullMemory.Span, _deferredFeatures, body); item.Length = written; - _ops.OnOutbound(new TransportData(item)); + _ops.OnOutbound(TransportData.Rent(item)); } catch (Exception ex) { @@ -184,12 +317,19 @@ private void EncodeDeferredResponse(ReadOnlySpan body) } } + public void ResumeBody() + { + } + public void Cleanup() { - _activeBodyEncoder?.Dispose(); - _activeBodyEncoder = null; - _deferredBodyOwner?.Dispose(); - _deferredBodyOwner = null; + _activeBodyWriter?.Dispose(); + _activeBodyWriter = null; + _activeBodyStream = null; + _activeStreamingReader = null; _deferredFeatures = null; + _ops.OnCancelTimer(DataRateCheck); } -} + + private void EnsureRateTimer() => _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/ChunkExtensionParser.cs b/src/TurboHTTP/Protocol/Syntax/Http11/ChunkExtensionParser.cs index f5ab80711..d9fe4ee79 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/ChunkExtensionParser.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/ChunkExtensionParser.cs @@ -1,3 +1,5 @@ +using TurboHTTP.Protocol.Semantics; + namespace TurboHTTP.Protocol.Syntax.Http11; /// @@ -118,14 +120,5 @@ private static bool TryAdvanceSemicolon(ReadOnlySpan data, ref int pos) return pos >= data.Length; } - private static bool IsTokenChar(byte b) - { - return b switch - { - (byte)'!' or (byte)'#' or (byte)'$' or (byte)'%' or (byte)'&' or (byte)'\'' - or (byte)'*' or (byte)'+' or (byte)'-' or (byte)'.' or (byte)'^' or (byte)'_' - or (byte)'`' or (byte)'|' or (byte)'~' => true, - _ => b is >= (byte)'0' and <= (byte)'9' or >= (byte)'A' and <= (byte)'Z' or >= (byte)'a' and <= (byte)'z' - }; - } + private static bool IsTokenChar(byte b) => HeaderValidation.IsTokenChar(b); } diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Client/HeaderBuilder.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Client/HeaderBuilder.cs index 2d5a33a70..fd27d17f6 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Client/HeaderBuilder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Client/HeaderBuilder.cs @@ -98,28 +98,11 @@ private static void AddHeaders(HeaderCollection collection, private static void AddHeader(HeaderCollection collection, string name, IEnumerable values) { - string? combined = null; - StringBuilder? sb = null; - - foreach (var value in values) + var combined = ContentHeaderClassifier.JoinHeaderValues(values); + if (combined is not null) { - if (combined is null) - { - combined = value; - } - else - { - sb ??= new StringBuilder(combined); - sb.Append(WellKnownHeaders.CommaSpace).Append(value); - } + collection.Add(name, combined); } - - if (combined is null) - { - return; - } - - collection.Add(name, sb?.ToString() ?? combined); } private static void AddTeHeader(HeaderCollection collection, IEnumerable values) diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientDecoder.cs index a0605bda8..4bf16db5a 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientDecoder.cs @@ -1,12 +1,12 @@ using System.Net; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.LineBased; -using TurboHTTP.Protocol.LineBased.Body; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http11.Options; namespace TurboHTTP.Protocol.Syntax.Http11.Client; -internal sealed class Http11ClientDecoder +internal sealed class Http11ClientDecoder(Http11ClientDecoderOptions options) { private enum Phase { @@ -16,53 +16,61 @@ private enum Phase Done } - private readonly Http11ClientDecoderOptions _options; - private readonly HeaderBlockReader _headerReader; + private readonly HeaderBlockReader _headerReader = new( + options.MaxHeaderBytes, options.MaxHeaderCount, options.HeaderLineMaxLength, options.AllowObsFold); private Phase _phase = Phase.StatusLine; private bool _bodyCompletedByEof; private Version _version = null!; private int _statusCode; private string _reason = null!; - private IBodyDecoder? _bodyDecoder; + private IBodyReader? _bodyReader; + private IFramingDecoder? _framingDecoder; private HttpResponseMessage? _response; private bool _isHttp09; public bool ConnectionWillClose { get; private set; } - public bool IsBodyStreaming => _phase == Phase.Body && !_isHttp09 && (_bodyDecoder?.IsBuffered != true); + public bool IsBodyStreaming => _phase == Phase.Body && !_isHttp09 && StreamingReader is not null; + + public bool IsQueueFull => StreamingReader?.IsFull ?? false; + + public IStreamingBodyReader? StreamingReader { get; private set; } internal bool HasActiveBody => _phase == Phase.Body; private static ReadOnlySpan HttpSlashPrefix => WellKnownHeaders.Http.Bytes.Span; - public Http11ClientDecoder(Http11ClientDecoderOptions options) - { - options.Validate(); - _options = options; - var s = options.Shared; - _headerReader = new HeaderBlockReader(s.MaxHeaderBytes, s.MaxHeaderCount, s.HeaderLineMaxLength, s.AllowObsFold); - } - - public DecodeOutcome Feed(ReadOnlySpan data, bool requestMethodWasHead, out int consumed) + public DecodeOutcome Feed(ReadOnlyMemory data, bool requestMethodWasHead, out int consumed) { consumed = 0; var pos = 0; + var span = data.Span; if (_phase == Phase.StatusLine) { - if (data.Length > 0 && !IsLikelyHttpResponse(data)) + if (span.Length > 0 && !IsLikelyHttpResponse(span)) { _isHttp09 = true; _version = HttpVersion.Version11; _statusCode = 200; _reason = "OK"; - _bodyDecoder = new CloseDelimitedBodyDecoder(); + + var (reader, decoder) = BodyReaderFactory.Create( + new BodyClassification(BodyFraming.Close, null), + options.ToBodyDecoderOptions()); + _bodyReader = reader; + _framingDecoder = decoder; + if (reader is IStreamingBodyReader streaming) + { + StreamingReader = streaming; + } + _phase = Phase.Body; } else { - if (!StatusLineParser.TryParse(data, out var ver, out var code, out var reason, out var slConsumed)) + if (!StatusLineParser.TryParse(span, out var ver, out var code, out var reason, out var slConsumed)) { return DecodeOutcome.NeedMore; } @@ -77,7 +85,7 @@ public DecodeOutcome Feed(ReadOnlySpan data, bool requestMethodWasHead, ou if (_phase == Phase.Headers) { - var result = _headerReader.Feed(data[pos..], out var hConsumed); + var result = _headerReader.Feed(span[pos..], out var hConsumed); pos += hConsumed; if (result == HeaderBlockResult.NeedMore) { @@ -91,29 +99,68 @@ public DecodeOutcome Feed(ReadOnlySpan data, bool requestMethodWasHead, ou _statusCode, headers, _version, requestMethodWasHead, connectionWillClose: ConnectionWillClose); - _bodyDecoder = BodyDecoderFactory.Create( - classification, - _options.Shared.StreamingThreshold, - _options.Shared.BufferPool, - _options.Shared.MaxBufferedBodySize, - _options.Shared.MaxStreamedBodySize); + var (reader, decoder) = BodyReaderFactory.Create(classification, options.ToBodyDecoderOptions()); + _bodyReader = reader; + _framingDecoder = decoder; + if (reader is IStreamingBodyReader streaming) + { + StreamingReader = streaming; + } _phase = Phase.Body; } if (_phase == Phase.Body) { - var slice = data[pos..]; - var done = _bodyDecoder!.Feed(slice, out var bConsumed); - pos += bConsumed; - consumed = pos; - if (done) + if (_bodyReader is BufferedBodyReader buffered) + { + var take = buffered.Feed(span[pos..]); + pos += take; + consumed = pos; + if (buffered.IsCompleted) + { + _phase = Phase.Done; + return DecodeOutcome.Complete; + } + + return _isHttp09 ? DecodeOutcome.HeadersReady : DecodeOutcome.NeedMore; + } + + if (StreamingReader is not null && _framingDecoder is not null) { - _phase = Phase.Done; - return DecodeOutcome.Complete; + var remaining = span[pos..]; + while (remaining.Length > 0) + { + var result = _framingDecoder.Decode(remaining, out var rawConsumed); + pos += rawConsumed; + + if (!result.Body.IsEmpty) + { + StreamingReader.TryEnqueue(result.Body); + } + + if (result.EndOfBody) + { + StreamingReader.Complete(); + _phase = Phase.Done; + consumed = pos; + return DecodeOutcome.Complete; + } + + if (rawConsumed == 0) + { + break; + } + + remaining = span[pos..]; + } + + consumed = pos; + return _isHttp09 ? DecodeOutcome.HeadersReady : DecodeOutcome.NeedMore; } - return _isHttp09 ? DecodeOutcome.HeadersReady : DecodeOutcome.NeedMore; + consumed = pos; + return DecodeOutcome.Complete; } consumed = pos; @@ -122,13 +169,25 @@ public DecodeOutcome Feed(ReadOnlySpan data, bool requestMethodWasHead, ou public bool SignalEof() { - if (_bodyDecoder is null) + if (StreamingReader is not null && _framingDecoder is not null) + { + var ok = _framingDecoder.OnEof(); + if (ok) + { + StreamingReader.Complete(); + } + + _bodyCompletedByEof = ok; + return ok; + } + + if (_framingDecoder is not null) { - return false; + _bodyCompletedByEof = _framingDecoder.OnEof(); + return _bodyCompletedByEof; } - _bodyCompletedByEof = _bodyDecoder.OnEof(); - return _bodyCompletedByEof; + return false; } internal bool IsBodyComplete => _phase == Phase.Done || _bodyCompletedByEof; @@ -141,7 +200,7 @@ public HttpResponseMessage GetResponse() } HttpContent content; - var bodyStream = _bodyDecoder?.GetBodyStream(); + var bodyStream = _bodyReader?.AsStream(); if (bodyStream is not null) { content = new StreamContent(bodyStream); @@ -155,10 +214,10 @@ public HttpResponseMessage GetResponse() { Version = _version, ReasonPhrase = _reason, - Content = content, + Content = content }; HeaderRouter.ApplyToResponse(msg, _headerReader.GetHeaders()); - if (_bodyDecoder?.Trailers is { Count: > 0 } trailers) + if (_framingDecoder?.Trailers is { Count: > 0 } trailers) { foreach (var (name, value) in trailers) { @@ -176,7 +235,9 @@ public void Reset() _version = null!; _statusCode = 0; _reason = null!; - _bodyDecoder = null; + _bodyReader = null; + _framingDecoder = null; + StreamingReader = null; _response = null; _isHttp09 = false; ConnectionWillClose = false; @@ -193,4 +254,4 @@ private static bool IsLikelyHttpResponse(ReadOnlySpan data) return HttpSlashPrefix[..data.Length].SequenceEqual(data); } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientEncoder.cs index c0806a726..74fd801e8 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientEncoder.cs @@ -1,41 +1,29 @@ -using Akka.Actor; using TurboHTTP.Protocol.LineBased; -using TurboHTTP.Protocol.LineBased.Body; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http11.Options; namespace TurboHTTP.Protocol.Syntax.Http11.Client; -internal sealed class Http11ClientEncoder +internal sealed class Http11ClientEncoder(Http11ClientEncoderOptions options) { - private readonly Http11ClientEncoderOptions _options; private readonly HeaderCollection _reusableHeaders = new(); - public Http11ClientEncoder(Http11ClientEncoderOptions options) - { - options.Validate(); - _options = options; - } - - public int Encode(Span destination, HttpRequestMessage request, IActorRef stageActor) + public int Encode(Span destination, HttpRequestMessage request, out Stream? bodyStream, out long? contentLength) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request.RequestUri); RequestValidator.Validate(request); - var contentLength = request.Content?.Headers.ContentLength; - var bodyStream = request.Content?.ReadAsStream(); - var bodyEncoder = BodyEncoderFactory.Create(bodyStream, contentLength, request.Version); + contentLength = request.Content?.Headers.ContentLength; + bodyStream = request.Content?.ReadAsStream(); var writer = SpanWriter.Create(destination); var targetStr = request.ResolveTarget(); RequestLineWriter.Write(ref writer, request.Method.Method, targetStr, request.Version); - HeaderBuilder.Build(request, _options, _reusableHeaders); + HeaderBuilder.Build(request, options, _reusableHeaders); HeaderBlockWriter.Write(ref writer, _reusableHeaders); - bodyEncoder?.Start(bodyStream!, stageActor); - return writer.BytesWritten; } } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs index fc0b50b43..9c92e0332 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs @@ -1,9 +1,12 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Akka.Actor; using Servus.Akka.Transport; using TurboHTTP.Client; using TurboHTTP.Internal; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Protocol.Body; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http11.Client; @@ -22,15 +25,28 @@ internal sealed class Http11ClientStateMachine : IClientStateMachine private HttpResponseMessage? _pendingBodyResponse; private bool _outboundBodyPending; private bool _connectionCloseReceived; + private readonly ConnectionBodyPool _pool = new(); + private IBodyWriter? _currentWriter; + private Stream? _currentBodyStream; + private IStreamingBodyReader? _activeStreamingReader; + private TransportBuffer? _heldBuffer; + private int _heldBufferOffset; + private bool _draining; + + internal sealed record BodyReadComplete(int BytesRead); + internal sealed record BodyReadFailed(Exception Reason); + internal sealed record StreamingSlotFreed; public bool CanAcceptRequest => _inFlightQueue.Count < _effectivePipelineDepth && !IsReconnecting && !_outboundBodyPending && - !_connectionCloseReceived; + !_connectionCloseReceived && !_draining; public bool HasInFlightRequests => _inFlightQueue.Count > 0; public bool IsReconnecting { get; private set; } + public bool ShouldPauseNetwork => _heldBuffer is not null || (_activeStreamingReader?.IsFull ?? false); + internal int PendingRequestCount { get @@ -53,25 +69,13 @@ public Http11ClientStateMachine( _ops = ops; _options = options; - var decoderOpts = new Http11ClientDecoderOptions - { - Shared = SharedHttpOptions.Default with - { - MaxHeaderBytes = options.Http1.MaxResponseHeadersLength * 1024, - MaxBufferedBodySize = options.MaxBufferedBodySize, - MaxStreamedBodySize = options.MaxStreamedBodySize, - }, - MaxPipelineDepth = options.Http1.MaxPipelineDepth, - }; - var encoderOpts = new Http11ClientEncoderOptions - { - AutoHost = options.Http1.AutoHost, - AutoAcceptEncoding = options.Http1.AutoAcceptEncoding, - }; + var decoderOpts = options.ToHttp11DecoderOptions(); + var encoderOpts = options.ToHttp11EncoderOptions(); _decoder = new Http11ClientDecoder(decoderOpts); _encoder = new Http11ClientEncoder(encoderOpts); - _effectivePipelineDepth = decoderOpts.MaxPipelineDepth; + // Pipeline depth is a connection concern, not a decoder concern — read it straight from options. + _effectivePipelineDepth = options.Http1.MaxPipelineDepth; } public void PreStart() @@ -98,12 +102,13 @@ public void OnRequest(HttpRequestMessage request) item = TransportBuffer.Rent(HttpMessageSize.Estimate(request, contentLength)); var span = item.FullMemory.Span; - item.Length = _encoder.Encode(span, request, _ops.StageActor); - _ops.OnOutbound(new TransportData(item)); + item.Length = _encoder.Encode(span, request, out var bodyStream, out var bodyContentLength); + _ops.OnOutbound(TransportData.Rent(item)); - if (request.Content is not null) + if (bodyStream is not null) { _outboundBodyPending = true; + StartBodyDrain(bodyStream, bodyContentLength, request.Version); } } catch (Exception ex) @@ -124,6 +129,45 @@ public void OnRequest(HttpRequestMessage request) } } + public void OnRequestCancelled(HttpRequestMessage request) + { + var found = false; + var temp = new Queue(); + while (_inFlightQueue.Count > 0) + { + var queued = _inFlightQueue.Dequeue(); + if (ReferenceEquals(queued, request)) + { + found = true; + request.Fail(new OperationCanceledException("Request cancelled by caller.")); + } + else + { + temp.Enqueue(queued); + } + } + + while (temp.Count > 0) + { + _inFlightQueue.Enqueue(temp.Dequeue()); + } + + if (!found) + { + return; + } + + if (_inFlightQueue.Count == 0) + { + _ops.OnOutbound(new DisconnectTransport(DisconnectReason.Graceful)); + return; + } + + _draining = true; + Tracing.For("Protocol").Debug(this, "HTTP/1.1: cancelled request, draining {0} remaining", + _inFlightQueue.Count); + } + public void DecodeServerData(ITransportInbound data) { switch (data) @@ -188,25 +232,34 @@ public void OnTimerFired(string name) public void OnBodyMessage(object msg) { + Tracing.For("Protocol").Debug(this, "OnBodyMessage: {0}", msg.GetType().Name); switch (msg) { - case OutboundBodyChunk chunk: - var buf = TransportBuffer.Rent(chunk.Length); - chunk.Owner.Memory.Span[..chunk.Length].CopyTo(buf.FullMemory.Span); - buf.Length = chunk.Length; - chunk.Owner.Dispose(); - _ops.OnOutbound(new TransportData(buf)); + case StreamingSlotFreed: + if (_heldBuffer is not null) + { + var buf = _heldBuffer; + var off = _heldBufferOffset; + _heldBuffer = null; + _heldBufferOffset = 0; + DecodeResponse(buf, off); + } + break; - case OutboundBodyComplete: - _outboundBodyPending = false; + case BodyReadComplete read: + HandleBodyRead(read.BytesRead); break; - case OutboundBodyFailed failed: + case BodyReadFailed failed: + Tracing.For("Protocol").Warning(this, "request body failed: {0}", failed.Reason.Message); _outboundBodyPending = false; + _currentWriter?.Dispose(); + _currentWriter = null; + _currentBodyStream = null; if (_inFlightQueue.Count > 0) { - var req = _inFlightQueue.Peek(); + var req = _inFlightQueue.Dequeue(); req.Fail(new HttpRequestException("Failed to encode HTTP/1.1 request body.", failed.Reason)); } @@ -214,32 +267,67 @@ public void OnBodyMessage(object msg) } } + public void OnOutboundFlushed() + { + } + public void Cleanup() { _inFlightQueue.Clear(); _pendingBodyResponse?.Dispose(); _pendingBodyResponse = null; _outboundBodyPending = false; + _activeStreamingReader = null; + _heldBuffer?.Dispose(); + _heldBuffer = null; + _heldBufferOffset = 0; _connectionCloseReceived = false; + _draining = false; + _currentWriter?.Dispose(); + _currentWriter = null; + _currentBodyStream = null; + _pool.Dispose(); _decoder.Reset(); } - private void DecodeResponse(TransportBuffer buffer) + private void DecodeResponse(TransportBuffer buffer, int startOffset = 0) { - var data = buffer.Memory.Span; + var memory = buffer.Memory; + var offset = startOffset; + var bufferHeld = false; try { - while (data.Length > 0) + while (offset < memory.Length) { var isHead = _inFlightQueue.Count > 0 && _inFlightQueue.Peek().Method == HttpMethod.Head; - var outcome = _decoder.Feed(data, isHead, out var consumed); - data = data[consumed..]; + var outcome = _decoder.Feed(memory[offset..], isHead, out var consumed); + offset += consumed; if (outcome == DecodeOutcome.NeedMore) { if (_decoder.IsBodyStreaming && _pendingBodyResponse is null) { _pendingBodyResponse = _decoder.GetResponse(); + if (_inFlightQueue.Count > 0) + { + _pendingBodyResponse.RequestMessage = _inFlightQueue.Peek(); + } + + _ops.OnResponse(_pendingBodyResponse); + + if (_activeStreamingReader is null && _decoder.StreamingReader is { } sr) + { + _activeStreamingReader = sr; + sr.SlotFreed += () => + _ops.StageActor.Tell(new StreamingSlotFreed(), ActorRefs.NoSender); + } + } + + if (_decoder.IsQueueFull && offset < memory.Length) + { + _heldBuffer = buffer; + _heldBufferOffset = offset; + bufferHeld = true; } return; @@ -247,8 +335,25 @@ private void DecodeResponse(TransportBuffer buffer) if (outcome == DecodeOutcome.Complete) { - var response = _pendingBodyResponse ?? _decoder.GetResponse(); - _pendingBodyResponse = null; + if (_pendingBodyResponse is not null) + { + _pendingBodyResponse = null; + _activeStreamingReader = null; + if (_inFlightQueue.Count > 0) + { + _inFlightQueue.Dequeue(); + } + + if (_draining && _inFlightQueue.Count == 0) + { + _draining = false; + } + + _decoder.Reset(); + continue; + } + + var response = _decoder.GetResponse(); if ((int)response.StatusCode is >= 100 and < 200) { @@ -271,11 +376,66 @@ private void DecodeResponse(TransportBuffer buffer) } _pendingBodyResponse = null; + _activeStreamingReader = null; _decoder.Reset(); } finally { - buffer.Dispose(); + if (!bufferHeld) + { + buffer.Dispose(); + } + } + } + + private void StartBodyDrain(Stream bodyStream, long? contentLength, Version httpVersion) + { + var (writer, _) = _pool.RentWriter( + hasBody: true, contentLength, httpVersion, + new BodyEncoderOptions { ChunkSize = _options.RequestBodyChunkSize }, + send: (owner, framedData) => + { + var ownerSpan = owner.Memory.Span; + var framedSpan = framedData.Span; + ref var ownerStart = ref MemoryMarshal.GetReference(ownerSpan); + ref var framedStart = ref MemoryMarshal.GetReference(framedSpan); + var offset = (int)Unsafe.ByteOffset(ref ownerStart, ref framedStart); + var buf = TransportBuffer.Wrap(owner, offset, framedData.Length); + _ops.OnOutbound(TransportData.Rent(buf)); + return default; + }); + + _currentWriter = writer; + _currentBodyStream = bodyStream; + Tracing.For("Protocol").Debug(this, "StartBodyDrain: writer={0}, contentLength={1}", writer?.GetType().Name, contentLength); + ReadNextChunk(); + } + + private void ReadNextChunk() + { + var mem = _currentWriter!.GetMemory(_options.RequestBodyChunkSize); + _currentBodyStream!.ReadAsync(mem).PipeTo( + _ops.StageActor, + success: bytesRead => new BodyReadComplete(bytesRead), + failure: ex => new BodyReadFailed(ex)); + } + + private void HandleBodyRead(int bytesRead) + { + if (bytesRead > 0) + { + _currentWriter!.Advance(bytesRead); + _currentWriter.FlushAsync(); + Tracing.For("Protocol").Trace(this, "request body chunk flushed (bytes={0})", bytesRead); + ReadNextChunk(); + } + else + { + _currentWriter!.CompleteAsync(); + _outboundBodyPending = false; + _currentWriter = null; + _currentBodyStream = null; + Tracing.For("Protocol").Debug(this, "request body complete"); } } @@ -288,18 +448,13 @@ private void HandleDisconnect(TransportDisconnected disconnect) if (_pendingBodyResponse is not null) { _decoder.SignalEof(); - if (_decoder.IsBodyComplete) - { - CompleteResponse(_pendingBodyResponse); - } - else if (_inFlightQueue.Count > 0) + if (_inFlightQueue.Count > 0) { - var req = _inFlightQueue.Dequeue(); - req.Fail(new HttpRequestException( - "HTTP/1.1 response body truncated: server closed before all bytes were received.")); + _inFlightQueue.Dequeue(); } _pendingBodyResponse = null; + _activeStreamingReader = null; } else if (_decoder.HasActiveBody) { @@ -323,6 +478,7 @@ private void HandleDisconnect(TransportDisconnected disconnect) if (_pendingBodyResponse is not null) { _pendingBodyResponse = null; + _activeStreamingReader = null; _decoder.Reset(); if (_inFlightQueue.Count > 0) { @@ -448,6 +604,11 @@ private void CompleteResponse(HttpResponseMessage response) request = _inFlightQueue.Dequeue(); } + if (_draining && _inFlightQueue.Count == 0) + { + _draining = false; + } + if (request is not null) { response.RequestMessage = request; diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/ConnectionReuseDecision.cs b/src/TurboHTTP/Protocol/Syntax/Http11/ConnectionReuseDecision.cs index 06ca06912..76a3b8c2e 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/ConnectionReuseDecision.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/ConnectionReuseDecision.cs @@ -46,7 +46,7 @@ public static ConnectionReuseDecision KeepAlive(string reason, TimeSpan? keepAli return KeepAliveCache.GetOrAdd(reason, static r => new ConnectionReuseDecision { CanReuse = true, - Reason = r, + Reason = r }); } @@ -55,7 +55,7 @@ public static ConnectionReuseDecision KeepAlive(string reason, TimeSpan? keepAli CanReuse = true, Reason = reason, KeepAliveTimeout = keepAliveTimeout, - MaxRequests = maxRequests, + MaxRequests = maxRequests }; } diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientDecoderOptions.cs index 0ea20ff51..8fc6fe2e8 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientDecoderOptions.cs @@ -2,23 +2,12 @@ namespace TurboHTTP.Protocol.Syntax.Http11.Options; internal sealed record Http11ClientDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int MaxPipelineDepth { get; init; } = 1; - - public static Http11ClientDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (MaxPipelineDepth <= 0) - { - throw new ArgumentException("MaxPipelineDepth must be greater than zero.", nameof(MaxPipelineDepth)); - } - - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required long StreamingThreshold { get; init; } + public required long MaxBufferedBodySize { get; init; } + public required long? MaxStreamedBodySize { get; init; } + public required int MaxHeaderBytes { get; init; } + public required int MaxHeaderCount { get; init; } + public required int HeaderLineMaxLength { get; init; } + public required int MaxChunkExtensionLength { get; init; } + public required bool AllowObsFold { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientEncoderOptions.cs index 3157bab3d..8efcd48e3 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientEncoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ClientEncoderOptions.cs @@ -2,19 +2,7 @@ namespace TurboHTTP.Protocol.Syntax.Http11.Options; internal sealed record Http11ClientEncoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public bool AutoHost { get; init; } = true; - public bool AutoAcceptEncoding { get; init; } = true; - - public static Http11ClientEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required bool AutoHost { get; init; } + public required bool AutoAcceptEncoding { get; init; } + public required int ChunkSize { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerDecoderOptions.cs index 449191a3c..301a70b45 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerDecoderOptions.cs @@ -2,23 +2,15 @@ namespace TurboHTTP.Protocol.Syntax.Http11.Options; internal sealed record Http11ServerDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int MaxPipelinedRequests { get; init; } = 10; - - public static Http11ServerDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (MaxPipelinedRequests <= 0) - { - throw new ArgumentException("MaxPipelinedRequests must be greater than zero.", nameof(MaxPipelinedRequests)); - } - - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required int MaxPipelinedRequests { get; init; } + public required int MaxChunkExtensionLength { get; init; } + public required long StreamingThreshold { get; init; } + public required long MaxBufferedBodySize { get; init; } + public required long? MaxStreamedBodySize { get; init; } + public required int MaxHeaderBytes { get; init; } + public required int MaxHeaderCount { get; init; } + public required int HeaderLineMaxLength { get; init; } + public required int RequestLineMaxLength { get; init; } + public required int MaxRequestTargetLength { get; init; } + public required bool AllowObsFold { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerEncoderOptions.cs index 0abcf08ab..3196149c4 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerEncoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Options/Http11ServerEncoderOptions.cs @@ -2,30 +2,8 @@ namespace TurboHTTP.Protocol.Syntax.Http11.Options; internal sealed record Http11ServerEncoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public TimeSpan KeepAliveTimeout { get; init; } = TimeSpan.FromSeconds(120); - public TimeSpan RequestHeadersTimeout { get; init; } = TimeSpan.FromSeconds(30); - public bool WriteDateHeader { get; init; } = true; - - public static Http11ServerEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (KeepAliveTimeout < TimeSpan.Zero) - { - throw new ArgumentException("KeepAliveTimeout must not be negative.", nameof(KeepAliveTimeout)); - } - - if (RequestHeadersTimeout <= TimeSpan.Zero) - { - throw new ArgumentException("RequestHeadersTimeout must be greater than zero.", nameof(RequestHeadersTimeout)); - } - - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required TimeSpan KeepAliveTimeout { get; init; } + public required TimeSpan RequestHeadersTimeout { get; init; } + public required bool WriteDateHeader { get; init; } + public required int MaxHeaderBytes { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs index 889454a52..0b1ce628c 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs @@ -1,13 +1,12 @@ -using Microsoft.AspNetCore.Http; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.LineBased; -using TurboHTTP.Protocol.LineBased.Body; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http11.Options; using TurboHTTP.Server.Context.Features; namespace TurboHTTP.Protocol.Syntax.Http11.Server; -internal sealed class Http11ServerDecoder +internal sealed class Http11ServerDecoder(Http11ServerDecoderOptions options) { private enum Phase { @@ -17,38 +16,37 @@ private enum Phase Done } - private readonly Http11ServerDecoderOptions _options; - private readonly HeaderBlockReader _headerReader; + private readonly HeaderBlockReader _headerReader = new(options.MaxHeaderBytes, options.MaxHeaderCount, options.HeaderLineMaxLength, options.AllowObsFold); private Phase _phase = Phase.RequestLine; private HttpMethod _method = null!; private string _target = null!; private Version _version = null!; - private IBodyDecoder? _bodyDecoder; - public Http11ServerDecoder(Http11ServerDecoderOptions options) - { - options.Validate(); - _options = options; - var s = options.Shared; - _headerReader = - new HeaderBlockReader(s.MaxHeaderBytes, s.MaxHeaderCount, s.HeaderLineMaxLength, s.AllowObsFold); - } - - public IBodyDecoder? CurrentBodyDecoder => _bodyDecoder; + public IBodyReader? CurrentBodyReader { get; private set; } + public IFramingDecoder? CurrentFramingDecoder { get; private set; } + public IStreamingBodyReader? StreamingReader { get; private set; } + public bool IsQueueFull => StreamingReader?.IsFull ?? false; - public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) + public DecodeOutcome Feed(ReadOnlyMemory data, out int consumed) { consumed = 0; var pos = 0; + var span = data.Span; if (_phase == Phase.RequestLine) { - if (!RequestLineParser.TryParse(data, _options.Shared.RequestLineMaxLength, out var method, out var target, out var version, out var rlConsumed)) + if (!RequestLineParser.TryParse(span, options.RequestLineMaxLength, out var method, out var target, out var version, out var rlConsumed)) { return DecodeOutcome.NeedMore; } + if (target.Length > options.MaxRequestTargetLength) + { + throw new HttpProtocolException( + $"Request target length {target.Length} exceeds limit ({options.MaxRequestTargetLength})."); + } + _method = method; _target = target; _version = version; @@ -58,7 +56,7 @@ public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) if (_phase == Phase.Headers) { - var result = _headerReader.Feed(data[pos..], out var hConsumed); + var result = _headerReader.Feed(span[pos..], out var hConsumed); pos += hConsumed; if (result == HeaderBlockResult.NeedMore) { @@ -67,27 +65,93 @@ public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) } var classification = BodySemantics.ClassifyRequest(_method, _headerReader.GetHeaders(), _version); - _bodyDecoder = BodyDecoderFactory.Create( - classification, - _options.Shared.StreamingThreshold, - _options.Shared.BufferPool, - _options.Shared.MaxBufferedBodySize, - _options.Shared.MaxStreamedBodySize); + var (reader, decoder) = BodyReaderFactory.Create(classification, options.ToBodyDecoderOptions()); + CurrentBodyReader = reader; + CurrentFramingDecoder = decoder; + if (reader is IStreamingBodyReader streaming) + { + StreamingReader = streaming; + } + + if (CurrentBodyReader is null || (CurrentBodyReader is BufferedBodyReader { IsCompleted: true })) + { + _phase = Phase.Done; + consumed = pos; + return DecodeOutcome.Complete; + } + _phase = Phase.Body; + + if (CurrentBodyReader is not BufferedBodyReader) + { + consumed = pos; + return DecodeOutcome.HeadersReady; + } } if (_phase == Phase.Body) { - var done = _bodyDecoder!.Feed(data[pos..], out var bConsumed); - pos += bConsumed; - consumed = pos; - if (done) + if (CurrentBodyReader is BufferedBodyReader bufferedBody) { - _phase = Phase.Done; - return DecodeOutcome.Complete; + var take = bufferedBody.Feed(span[pos..]); + pos += take; + consumed = pos; + if (bufferedBody.IsCompleted) + { + _phase = Phase.Done; + return DecodeOutcome.Complete; + } + + return DecodeOutcome.NeedMore; + } + + if (StreamingReader is not null && CurrentFramingDecoder is not null) + { + var remaining = span[pos..]; + while (remaining.Length > 0) + { + var result = CurrentFramingDecoder.Decode(remaining, out var rawConsumed); + pos += rawConsumed; + + if (!result.Body.IsEmpty) + { + if (!StreamingReader.TryEnqueue(result.Body)) + { + if (result.EndOfBody) + { + StreamingReader.Complete(); + _phase = Phase.Done; + consumed = pos; + return DecodeOutcome.Complete; + } + + consumed = pos; + return DecodeOutcome.NeedMore; + } + } + + if (result.EndOfBody) + { + StreamingReader.Complete(); + _phase = Phase.Done; + consumed = pos; + return DecodeOutcome.Complete; + } + + if (rawConsumed == 0) + { + break; + } + + remaining = span[pos..]; + } + + consumed = pos; + return DecodeOutcome.NeedMore; } - return DecodeOutcome.NeedMore; + consumed = pos; + return DecodeOutcome.Complete; } consumed = pos; @@ -100,7 +164,7 @@ public bool HasConnectionClose { foreach (var v in _headerReader.GetHeaders().GetValues(WellKnownHeaders.Connection)) { - if (string.Equals(v, WellKnownHeaders.CloseValue, StringComparison.OrdinalIgnoreCase)) + if (ConnectionHeaderSemantics.HasCloseOption(v)) { return true; } @@ -111,25 +175,27 @@ public bool HasConnectionClose public TurboHttpRequestFeature GetRequestFeature() { - var headers = new HeaderDictionary(); - HeaderRouter.ApplyToHeaderDictionary(headers, _headerReader.GetHeaders()); - var body = _bodyDecoder?.GetBodyStream() ?? Stream.Null; + var body = CurrentBodyReader?.AsStream() ?? Stream.Null; - return new TurboHttpRequestFeature + var feature = new TurboHttpRequestFeature { Protocol = _version switch { - { Major: 1, Minor: 0 } => "HTTP/1.0", - { Major: 1, Minor: 1 } => "HTTP/1.1", - _ => "HTTP/1.1" + { Major: 1, Minor: 0 } => WellKnownHeaders.Http10, + { Major: 1, Minor: 1 } => WellKnownHeaders.Http11, + _ => WellKnownHeaders.Http11 }, Method = _method.Method, Path = ParsePath(_target), QueryString = ParseQueryString(_target), RawTarget = _target, - Headers = headers, - Body = body, + Body = body }; + + // Populate directly into the feature's header dictionary, avoiding a throwaway + // HeaderDictionary allocation plus the copy loop in the Headers setter. + HeaderRouter.ApplyToHeaderDictionary(feature.Headers, _headerReader.GetHeaders()); + return feature; } private static string ParsePath(string target) @@ -151,7 +217,9 @@ public void Reset() _method = null!; _target = null!; _version = null!; - _bodyDecoder = null; + CurrentBodyReader = null; + CurrentFramingDecoder = null; + StreamingReader = null; _headerReader.Reset(); } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs index 550513b5e..2abe29275 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs @@ -1,35 +1,14 @@ using System.Net; using Microsoft.AspNetCore.Http.Features; using TurboHTTP.Protocol.LineBased; -using TurboHTTP.Protocol.LineBased.Body; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http11.Options; namespace TurboHTTP.Protocol.Syntax.Http11.Server; -internal sealed class Http11ServerEncoder +internal sealed class Http11ServerEncoder(Http11ServerEncoderOptions options) { - private readonly Http11ServerEncoderOptions _options; private readonly HeaderCollection _reusableHeaders = new(); - private IBodyEncoder? _activeBodyEncoder; - - public Http11ServerEncoder(Http11ServerEncoderOptions options) - { - options.Validate(); - _options = options; - } - - public void SetActiveBodyEncoder(IBodyEncoder encoder) - { - _activeBodyEncoder?.Dispose(); - _activeBodyEncoder = encoder; - } - - public void CancelActiveBody() - { - _activeBodyEncoder?.Dispose(); - _activeBodyEncoder = null; - } public int Encode(Span destination, IFeatureCollection features, bool isChunked = false, bool connectionClose = false) { @@ -40,7 +19,6 @@ public int Encode(Span destination, IFeatureCollection features, bool isCh StatusLineWriter.Write(ref writer, HttpVersion.Version11, statusCode); _reusableHeaders.Clear(); - var headers = _reusableHeaders; var responseHeaders = responseFeature?.Headers; if (responseHeaders is not null) { @@ -55,7 +33,7 @@ public int Encode(Span destination, IFeatureCollection features, bool isCh { if (v is not null) { - headers.Add(h.Key, v); + _reusableHeaders.Add(h.Key, v); } } } @@ -63,27 +41,27 @@ public int Encode(Span destination, IFeatureCollection features, bool isCh if (isChunked) { - if (!headers.Contains(WellKnownHeaders.TransferEncoding)) + if (!_reusableHeaders.Contains(WellKnownHeaders.TransferEncoding)) { - headers.Add(WellKnownHeaders.TransferEncoding, WellKnownHeaders.ChunkedValue); + _reusableHeaders.Add(WellKnownHeaders.TransferEncoding, WellKnownHeaders.ChunkedValue); } } - else if (!headers.Contains(WellKnownHeaders.ContentLength)) + else if (!_reusableHeaders.Contains(WellKnownHeaders.ContentLength)) { - headers.Add(WellKnownHeaders.ContentLength, ContentLengthCache.GetValue(0L)); + _reusableHeaders.Add(WellKnownHeaders.ContentLength, ContentLengthCache.GetValue(0L)); } - if (_options.WriteDateHeader && !headers.Contains(WellKnownHeaders.Date)) + if (options.WriteDateHeader && !_reusableHeaders.Contains(WellKnownHeaders.Date)) { - headers.Add(WellKnownHeaders.Date, DateHeaderCache.GetValue()); + _reusableHeaders.Add(WellKnownHeaders.Date, DateHeaderCache.GetValue()); } if (connectionClose) { - headers.Add(WellKnownHeaders.Connection, WellKnownHeaders.CloseValue); + _reusableHeaders.Add(WellKnownHeaders.Connection, WellKnownHeaders.CloseValue); } - HeaderBlockWriter.Write(ref writer, headers); + HeaderBlockWriter.Write(ref writer, _reusableHeaders); // Body encoding is handled separately via the BodySink return writer.BytesWritten; diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs index 9fb7c777f..3b240771d 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs @@ -1,72 +1,96 @@ using System.Net; -using Akka.Event; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Akka.Actor; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol.LineBased.Body; -using TurboHTTP.Protocol.Syntax.Http11.Options; +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http2.Server; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Streams.Stages.Server; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http11.Server; internal sealed class Http11ServerStateMachine : IServerStateMachine { + private const string KeepAliveTimer = "keep-alive"; + private const string RequestHeadersTimer = "request-headers"; + private const string BodyConsumptionTimer = "body-consumption"; + private const string BodyReadTimer = "body-read"; + private const string DataRateCheck = "data-rate-check"; + private readonly IServerStageOperations _ops; private readonly Http11ServerDecoder _decoder; private readonly Http11ServerEncoder _encoder; - private readonly int _maxPipelineDepth; private readonly TimeSpan _keepAliveTimeout; private readonly TimeSpan _requestHeadersTimeout; - private int _requestsPipelined; + private readonly TimeSpan _bodyConsumptionTimeout; + private readonly TimeSpan _bodyReadTimeout; + private readonly BodyEncoderOptions _bodyEncoderOptions; + private readonly long _maxRequestBodySize; + private readonly Http2ConnectionOptions _h2UpgradeOptions; + + private readonly DataRateMonitor _requestRate; + private readonly DataRateMonitor _responseRate; + private readonly TimeProvider _clock; + + private long Now() => _clock.GetUtcNow().ToUnixTimeMilliseconds(); + private int _pendingResponseCount; private bool _outboundBodyPending; private bool _requestHeadersTimerActive; + private bool _bodyReadTimerActive; private bool _draining; - private readonly TurboServerOptions _serverOptions; + private bool _bodyStreaming; + private IStreamingBodyReader? _activeStreamingReader; + + private readonly ConnectionBodyPool _pool = new(); + private IBodyWriter? _activeResponseBodyWriter; + private Stream? _activeResponseBodyStream; + private IFeatureCollection? _activeResponseFeatures; + + internal readonly record struct ResponseBodyReadComplete(int BytesRead); + internal readonly record struct ResponseBodyReadFailed(Exception Reason); public bool CanAcceptResponse => !_outboundBodyPending && _pendingResponseCount > 0; public bool ShouldComplete { get; private set; } - public int MaxQueuedRequests => _maxPipelineDepth; + public bool ShouldPauseNetwork => _activeStreamingReader?.IsFull ?? false; + public int MaxQueuedRequests { get; } - public Http11ServerStateMachine(TurboServerOptions options, IServerStageOperations ops) + public Http11ServerStateMachine(Http1ConnectionOptions options, Http2ConnectionOptions h2UpgradeOptions, + IServerStageOperations ops, TimeProvider? timeProvider = null) { _ops = ops ?? throw new ArgumentNullException(nameof(ops)); ArgumentNullException.ThrowIfNull(options); - _serverOptions = options; - - var shared = SharedHttpOptions.Default with - { - MaxBufferedBodySize = options.BodyBufferThreshold, - MaxStreamedBodySize = options.Http1.MaxRequestBodySize, - MaxHeaderBytes = options.Http1.MaxHeaderListSize, - HeaderLineMaxLength = options.Http1.MaxRequestLineLength, - RequestLineMaxLength = options.Http1.MaxRequestLineLength, - }; - - var encOpts = new Http11ServerEncoderOptions - { - Shared = shared, - KeepAliveTimeout = options.Http1.KeepAliveTimeout ?? options.Limits.KeepAliveTimeout, - RequestHeadersTimeout = options.Http1.RequestHeadersTimeout ?? options.Limits.RequestHeadersTimeout, - }; - - var decOpts = new Http11ServerDecoderOptions + ArgumentNullException.ThrowIfNull(h2UpgradeOptions); + _h2UpgradeOptions = h2UpgradeOptions; + _bodyConsumptionTimeout = options.BodyConsumptionTimeout; + _bodyReadTimeout = options.BodyReadTimeout; + _bodyEncoderOptions = options.ToBodyEncoderOptions(); + _maxRequestBodySize = options.Limits.MaxRequestBodySize; + _clock = timeProvider ?? TimeProvider.System; + + var rate = options.ToRateMonitor(); + _requestRate = new DataRateMonitor(rate.MinRequestBodyDataRate, rate.MinRequestBodyDataRateGracePeriod); + _responseRate = new DataRateMonitor(rate.MinResponseDataRate, rate.MinResponseDataRateGracePeriod); + + var decOpts = options.ToHttp11DecoderOptions(); + var encOpts = options.ToHttp11EncoderOptions(); + + if (decOpts.MaxPipelinedRequests <= 0) { - Shared = shared, - MaxPipelinedRequests = options.Http1.MaxPipelinedRequests, - }; - - encOpts.Validate(); - decOpts.Validate(); + throw new ArgumentException("MaxPipelinedRequests must be greater than zero.", nameof(options)); + } _decoder = new Http11ServerDecoder(decOpts); _encoder = new Http11ServerEncoder(encOpts); _keepAliveTimeout = encOpts.KeepAliveTimeout; _requestHeadersTimeout = encOpts.RequestHeadersTimeout; - _maxPipelineDepth = decOpts.MaxPipelinedRequests; + MaxQueuedRequests = decOpts.MaxPipelinedRequests; } public void PreStart() @@ -80,49 +104,74 @@ public void DecodeClientData(ITransportInbound data) return; } + if (buffer.Length == 0) + { + return; + } + try { var span = buffer.Memory.Span; var pos = 0; - if (_draining && _decoder.CurrentBodyDecoder is { } bodyDecoder) + if (_draining && _decoder.CurrentFramingDecoder is { } drainingDecoder) { - var drained = bodyDecoder.Drain(span[pos..]); + var drained = drainingDecoder.Drain(span[pos..]); pos += drained; + _requestRate.Observe(0, drained, Now()); + EnsureRateTimer(); - if (bodyDecoder.IsComplete) + if (drainingDecoder.IsComplete) { _draining = false; + _ops.OnCancelTimer(BodyConsumptionTimer); + _requestRate.Remove(0); + _decoder.Reset(); + } + } + else if (_bodyStreaming && _decoder.StreamingReader is not null) + { + var outcome = _decoder.Feed(buffer.Memory[pos..], out var bodyConsumed); + pos += bodyConsumed; + _requestRate.Observe(0, bodyConsumed, Now()); + EnsureRateTimer(); + + if (outcome == DecodeOutcome.Complete) + { + _bodyStreaming = false; + _activeStreamingReader = null; + _requestRate.Remove(0); _decoder.Reset(); } } - // Schedule request headers timeout if not already active - if (!_requestHeadersTimerActive && _pendingResponseCount == 0 && _requestHeadersTimeout > TimeSpan.Zero) + if (!_requestHeadersTimerActive && _pendingResponseCount == 0 && !_bodyStreaming + && !_outboundBodyPending + && _requestHeadersTimeout > TimeSpan.Zero) { - _ops.OnScheduleTimer("request-headers", _requestHeadersTimeout); + _ops.OnScheduleTimer(RequestHeadersTimer, _requestHeadersTimeout); _requestHeadersTimerActive = true; + Tracing.For("Protocol").Debug(this, "request headers timer scheduled ({0}ms)", _requestHeadersTimeout.TotalMilliseconds); } - while (pos < span.Length) + while (pos < span.Length && !_bodyStreaming) { - var outcome = _decoder.Feed(span[pos..], out var consumed); + var outcome = _decoder.Feed(buffer.Memory[pos..], out var consumed); pos += consumed; - if (outcome != DecodeOutcome.Complete) + if (outcome == DecodeOutcome.NeedMore) { break; } - // Cancel the request headers timer once headers are complete if (_requestHeadersTimerActive) { - _ops.OnCancelTimer("request-headers"); + _ops.OnCancelTimer(RequestHeadersTimer); _requestHeadersTimerActive = false; + Tracing.For("Protocol").Debug(this, "request headers timer cancelled (headers complete)"); } - _requestsPipelined++; - if (_requestsPipelined > _maxPipelineDepth) + if (_pendingResponseCount >= MaxQueuedRequests) { ShouldComplete = true; break; @@ -134,11 +183,11 @@ public void DecodeClientData(ITransportInbound data) } var feature = _decoder.GetRequestFeature(); - var hasBody = feature.Body != Stream.Null; + var hasBody = outcome == DecodeOutcome.HeadersReady || feature.Body != Stream.Null; var features = FeatureCollectionFactory.Create(feature, hasBody, _ops.Services, _ops.ConnectionFeature, - _ops.TlsHandshakeFeature, _serverOptions.Limits.MaxRequestBodySize); + _ops.TlsHandshakeFeature, _maxRequestBodySize); - if (!ShouldComplete && feature.Protocol == "HTTP/1.0") + if (!ShouldComplete && feature.Protocol == WellKnownHeaders.Http10) { ShouldComplete = true; } @@ -150,12 +199,49 @@ public void DecodeClientData(ITransportInbound data) } _pendingResponseCount++; + Tracing.For("Protocol").Debug(this, "request dispatched (pending={0})", _pendingResponseCount); _ops.OnRequest(features); + + if (outcome == DecodeOutcome.HeadersReady) + { + _bodyStreaming = true; + Tracing.For("Protocol").Trace(this, "request body streaming started"); + + if (_decoder.StreamingReader is { } sr && _activeStreamingReader is null) + { + _activeStreamingReader = sr; + sr.SlotFreed += () => + _ops.StageActor.Tell(new BodyResumed(), ActorRefs.NoSender); + } + + if (pos < buffer.Memory.Length) + { + var bodyOutcome = _decoder.Feed(buffer.Memory[pos..], out var bodyConsumed); + pos += bodyConsumed; + _requestRate.Observe(0, bodyConsumed, Now()); + EnsureRateTimer(); + + if (bodyOutcome == DecodeOutcome.Complete) + { + _bodyStreaming = false; + _activeStreamingReader = null; + _requestRate.Remove(0); + _decoder.Reset(); + continue; + } + } + + break; + } + _decoder.Reset(); } + + ReconcileBodyReadTimer(); } - catch (Exception) + catch (Exception ex) { + Tracing.For("Protocol").Warning(this, "Failed to decode HTTP/1.1 request: {0}", ex.Message); ShouldComplete = true; } finally @@ -164,6 +250,20 @@ public void DecodeClientData(ITransportInbound data) } } + private void ReconcileBodyReadTimer() + { + if (_bodyStreaming && _bodyReadTimeout > TimeSpan.Zero) + { + _ops.OnScheduleTimer(BodyReadTimer, _bodyReadTimeout); + _bodyReadTimerActive = true; + } + else if (_bodyReadTimerActive) + { + _ops.OnCancelTimer(BodyReadTimer); + _bodyReadTimerActive = false; + } + } + public void OnResponse(IFeatureCollection features) { if (_pendingResponseCount == 0) @@ -172,6 +272,8 @@ public void OnResponse(IFeatureCollection features) } _pendingResponseCount--; + Tracing.For("Protocol").Debug(this, "response received (status={0}, pending={1})", + features.Get()?.StatusCode ?? 0, _pendingResponseCount); var responseFeature = features.Get(); var responseBody = features.Get(); @@ -180,49 +282,179 @@ public void OnResponse(IFeatureCollection features) var suppressBody = statusCode is >= 100 and < 200 or 204 or 304; var contentLength = ExtractContentLength(responseFeature); - var hasExplicitChunked = responseFeature?.Headers?.Any(h => - h.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) - && h.Value.Any(v => v.Equals(WellKnownHeaders.ChunkedValue, StringComparison.OrdinalIgnoreCase))) ?? false; + var hasExplicitChunked = responseFeature?.Headers.Any(h => + h.Key.Equals(WellKnownHeaders.TransferEncoding, StringComparison.OrdinalIgnoreCase) + && h.Value.Any(v => v!.Equals(WellKnownHeaders.ChunkedValue, StringComparison.OrdinalIgnoreCase))) ?? false; var isChunked = !suppressBody && (contentLength is null || hasExplicitChunked); - var responseBuffer = TransportBuffer.Rent(8192); + var estimatedSize = EstimateResponseHeaderSize(responseFeature); + var responseBuffer = TransportBuffer.Rent(estimatedSize); var span = responseBuffer.FullMemory.Span; var written = _encoder.Encode(span, features, isChunked, connectionClose: ShouldComplete); responseBuffer.Length = written; - _ops.OnOutbound(new TransportData(responseBuffer)); + _ops.OnOutbound(TransportData.Rent(responseBuffer)); if (suppressBody) { if (!ShouldComplete && _keepAliveTimeout > TimeSpan.Zero && _pendingResponseCount == 0) { - _ops.OnScheduleTimer("keep-alive", _keepAliveTimeout); + _ops.OnScheduleTimer(KeepAliveTimer, _keepAliveTimeout); } return; } - if (!_draining && _decoder.CurrentBodyDecoder is { IsComplete: false }) + if (_decoder.CurrentBodyReader is { IsCompleted: false }) { + if (_bodyStreaming) + { + _bodyStreaming = false; + _activeStreamingReader = null; + if (_bodyReadTimerActive) + { + _ops.OnCancelTimer(BodyReadTimer); + _bodyReadTimerActive = false; + } + } + _draining = true; + Tracing.For("Protocol").Debug(this, "draining unconsumed request body"); + + if (_bodyConsumptionTimeout > TimeSpan.Zero) + { + _ops.OnScheduleTimer(BodyConsumptionTimer, _bodyConsumptionTimeout); + } } if (responseBody is TurboHttpResponseBodyFeature turboBody) { + if (turboBody.TryGetBufferedBody(out var bufferedBody)) + { + EmitBufferedBody(features, bufferedBody, contentLength); + return; + } + _outboundBodyPending = true; + _activeResponseFeatures = features; + Tracing.For("Protocol").Debug(this, "response body writer starting (chunked={0})", isChunked); var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength, HttpVersion.Version11); - if (encoder is not null) + var (writer, _) = _pool.RentWriter( + hasBody: true, contentLength, HttpVersion.Version11, _bodyEncoderOptions, + send: (owner, framedData) => + { + var ownerSpan = owner.Memory.Span; + var framedSpan = framedData.Span; + ref var ownerStart = ref MemoryMarshal.GetReference(ownerSpan); + ref var framedStart = ref MemoryMarshal.GetReference(framedSpan); + var offset = (int)Unsafe.ByteOffset(ref ownerStart, ref framedStart); + _responseRate.Observe(0, framedData.Length, Now()); + EnsureRateTimer(); + var buf = TransportBuffer.Wrap(owner, offset, framedData.Length); + _ops.OnOutbound(TransportData.Rent(buf)); + return default; + }); + + _activeResponseBodyWriter = writer; + _activeResponseBodyStream = bodyStream; + + + ReadNextResponseChunk(); + } + else + { + if (!ShouldComplete && _keepAliveTimeout > TimeSpan.Zero && _pendingResponseCount == 0) { - _encoder.SetActiveBodyEncoder(encoder); - encoder.Start(bodyStream, _ops.StageActor); + _ops.OnScheduleTimer(KeepAliveTimer, _keepAliveTimeout); } } + } + + + private void EmitBufferedBody(IFeatureCollection features, ReadOnlyMemory body, long? contentLength) + { + var (writer, _) = _pool.RentWriter( + hasBody: true, contentLength, HttpVersion.Version11, _bodyEncoderOptions, + send: (owner, framedData) => + { + var ownerSpan = owner.Memory.Span; + var framedSpan = framedData.Span; + ref var ownerStart = ref MemoryMarshal.GetReference(ownerSpan); + ref var framedStart = ref MemoryMarshal.GetReference(framedSpan); + var offset = (int)Unsafe.ByteOffset(ref ownerStart, ref framedStart); + _responseRate.Observe(0, framedData.Length, Now()); + EnsureRateTimer(); + var buf = TransportBuffer.Wrap(owner, offset, framedData.Length); + _ops.OnOutbound(TransportData.Rent(buf)); + return default; + }); + + if (body.Length > 0) + { + var remaining = body; + while (remaining.Length > 0) + { + var take = Math.Min(remaining.Length, _bodyEncoderOptions.ChunkSize); + var dest = writer.GetMemory(take); + remaining.Span[..take].CopyTo(dest.Span); + writer.Advance(take); + writer.FlushAsync(); + remaining = remaining[take..]; + } + } + + writer.CompleteAsync(); + _ops.OnResponseBodyComplete(features); + + Tracing.For("Protocol").Debug(this, "response body complete (buffered, bytes={0})", body.Length); + if (!ShouldComplete && _keepAliveTimeout > TimeSpan.Zero && _pendingResponseCount == 0) + { + _ops.OnScheduleTimer(KeepAliveTimer, _keepAliveTimeout); + } + } + + private void ReadNextResponseChunk() + { + var mem = _activeResponseBodyWriter!.GetMemory(_bodyEncoderOptions.ChunkSize); + var vt = _activeResponseBodyStream!.ReadAsync(mem); + if (vt.IsCompletedSuccessfully) + { + HandleResponseBodyRead(vt.Result); + return; + } + + vt.PipeTo( + _ops.StageActor, + success: bytesRead => new ResponseBodyReadComplete(bytesRead), + failure: ex => new ResponseBodyReadFailed(ex)); + } + + private void HandleResponseBodyRead(int bytesRead) + { + if (bytesRead > 0) + { + _activeResponseBodyWriter!.Advance(bytesRead); + _activeResponseBodyWriter.FlushAsync(); + Tracing.For("Protocol").Trace(this, "response body chunk flushed (bytes={0})", bytesRead); + ReadNextResponseChunk(); + } else { + _activeResponseBodyWriter!.CompleteAsync(); + _outboundBodyPending = false; + _activeResponseBodyWriter = null; + _activeResponseBodyStream = null; + _responseRate.Remove(0); + if (_activeResponseFeatures is not null) + { + _ops.OnResponseBodyComplete(_activeResponseFeatures); + _activeResponseFeatures = null; + } + + Tracing.For("Protocol").Debug(this, "response body complete"); if (!ShouldComplete && _keepAliveTimeout > TimeSpan.Zero && _pendingResponseCount == 0) { - _ops.OnScheduleTimer("keep-alive", _keepAliveTimeout); + _ops.OnScheduleTimer(KeepAliveTimer, _keepAliveTimeout); } } } @@ -233,46 +465,100 @@ public void OnDownstreamFinished() public void OnTimerFired(string name) { - if (name == "keep-alive") + if (name == KeepAliveTimer) { - // Keep-alive timeout expired, close the connection + Tracing.For("Protocol").Info(this, "keep-alive timeout — closing connection"); ShouldComplete = true; } - else if (name == "request-headers") + else if (name == RequestHeadersTimer) { - // Request headers timeout expired before headers were fully received + Tracing.For("Protocol").Info(this, + "request headers timeout (outboundBodyPending={0}, pending={1})", + _outboundBodyPending, _pendingResponseCount); _requestHeadersTimerActive = false; ShouldComplete = true; } + else if (name == BodyConsumptionTimer) + { + Tracing.For("Protocol").Info(this, "body consumption timeout — closing connection"); + _draining = false; + ShouldComplete = true; + } + else if (name == BodyReadTimer) + { + Tracing.For("Protocol").Info(this, "body read timeout — closing connection"); + _bodyReadTimerActive = false; + ShouldComplete = true; + } + else if (name == DataRateCheck) + { + var violations = new List(); + _requestRate.Check(Now(), violations); + _responseRate.Check(Now(), violations); + + if (violations.Count > 0) + { + Tracing.For("Protocol").Warning(this, + "data rate violation (reqRate={0}, respRate={1}, paused={2})", + _requestRate.Count, _responseRate.Count, ShouldPauseNetwork); + ShouldComplete = true; + return; + } + + if (_requestRate.Count > 0 || _responseRate.Count > 0) + { + _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); + } + } } public void OnBodyMessage(object msg) { switch (msg) { - case OutboundBodyChunk chunk: - var buf = TransportBuffer.Rent(chunk.Length); - chunk.Owner.Memory.Span[..chunk.Length].CopyTo(buf.FullMemory.Span); - buf.Length = chunk.Length; - chunk.Owner.Dispose(); - _ops.OnOutbound(new TransportData(buf)); + case ResponseBodyReadComplete read: + HandleResponseBodyRead(read.BytesRead); break; - case OutboundBodyComplete: + case ResponseBodyReadFailed failed: _outboundBodyPending = false; - // Schedule keep-alive timer after body completes if needed - if (!ShouldComplete && _keepAliveTimeout > TimeSpan.Zero && _pendingResponseCount == 0) - { - _ops.OnScheduleTimer("keep-alive", _keepAliveTimeout); - } - + _activeResponseBodyWriter?.Dispose(); + _activeResponseBodyWriter = null; + _activeResponseBodyStream = null; + _responseRate.Remove(0); + Tracing.For("Protocol").Warning(this, "response body failed: {0}", failed.Reason.Message); break; + } + } - case OutboundBodyFailed failed: - _outboundBodyPending = false; - _ops.Log.Warning("Failed to encode HTTP/1.1 response body: {0}", failed.Reason.Message); - break; + public void OnOutboundFlushed() + { + } + + private static int EstimateResponseHeaderSize(IHttpResponseFeature? responseFeature) + { + const int statusLineOverhead = 32; + const int perHeaderOverhead = 4; + const int trailingCrlf = 2; + const int minimumSize = 256; + + if (responseFeature?.Headers is null) + { + return minimumSize; } + + var estimate = statusLineOverhead + trailingCrlf; + foreach (var header in responseFeature.Headers) + { + estimate += header.Key.Length + perHeaderOverhead; + foreach (var v in header.Value) + { + estimate += v?.Length ?? 0; + } + } + + estimate += 128; + return Math.Max(minimumSize, estimate); } private static long? ExtractContentLength(IHttpResponseFeature? responseFeature) @@ -284,12 +570,10 @@ public void OnBodyMessage(object msg) foreach (var header in responseFeature.Headers) { - if (header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + if (header.Key.Equals(WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase) && + header.Value.FirstOrDefault() is { } value && ContentLengthSemantics.TryParse(value, out var length)) { - if (header.Value.FirstOrDefault() is { } value && long.TryParse(value, out var length)) - { - return length; - } + return length; } } @@ -310,10 +594,10 @@ private bool TryHandleH2cUpgrade(IFeatureCollection features) return false; } - var hasUpgrade = requestHeaders.TryGetValue("Upgrade", out var upgradeValue) + var hasUpgrade = requestHeaders.TryGetValue(WellKnownHeaders.Upgrade, out var upgradeValue) && !string.IsNullOrEmpty(upgradeValue) - && upgradeValue.ToString().Split(',') - .Any(v => v.Trim().Equals("h2c", StringComparison.OrdinalIgnoreCase)); + && ConnectionHeaderSemantics.Parse(upgradeValue.ToString()) + .Contains("h2c", StringComparer.OrdinalIgnoreCase); if (!hasUpgrade) { @@ -329,24 +613,42 @@ private bool TryHandleH2cUpgrade(IFeatureCollection features) var responseBuffer = TransportBuffer.Rent(responseBytes.Length); responseBytes.CopyTo(responseBuffer.FullMemory.Span); responseBuffer.Length = responseBytes.Length; - _ops.OnOutbound(new TransportData(responseBuffer)); + _ops.OnOutbound(TransportData.Rent(responseBuffer)); - switchable.RequestProtocolSwitch(ops => new Http2ServerStateMachine(_serverOptions, ops)); + switchable.RequestProtocolSwitch(ops => new Http2ServerStateMachine(_h2UpgradeOptions, ops)); return true; } + internal void ResumeBody() + { + } + public void Cleanup() { - _encoder.CancelActiveBody(); + _activeResponseBodyWriter?.Dispose(); + _activeResponseBodyWriter = null; + _activeResponseBodyStream = null; + _pool.Dispose(); _outboundBodyPending = false; _pendingResponseCount = 0; + _activeStreamingReader = null; if (_requestHeadersTimerActive) { - _ops.OnCancelTimer("request-headers"); + _ops.OnCancelTimer(RequestHeadersTimer); _requestHeadersTimerActive = false; } - _ops.OnCancelTimer("keep-alive"); + if (_bodyReadTimerActive) + { + _ops.OnCancelTimer(BodyReadTimer); + _bodyReadTimerActive = false; + } + + _ops.OnCancelTimer(KeepAliveTimer); + _ops.OnCancelTimer(BodyConsumptionTimer); + _ops.OnCancelTimer(DataRateCheck); } + + private void EnsureRateTimer() => _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientDecoder.cs index 1a1c0adc7..98762869e 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientDecoder.cs @@ -4,19 +4,25 @@ namespace TurboHTTP.Protocol.Syntax.Http2.Client; -internal sealed class Http2ClientDecoder( - int maxHeaderSize = 16 * 1024, - int maxTotalHeaderSize = 64 * 1024) +internal sealed class Http2ClientDecoder(int maxHeaderSize, int maxTotalHeaderSize) { private const string PseudoHeaderSection = "RFC 9113 §8.1.2.2"; private const string UppercaseSection = "RFC 9113 §8.2.1"; private const string TokenSection = "RFC 9113 §10.3"; - private const string FieldValueSection = "RFC 9113 §10.3"; private const string ConnectionSection = "RFC 9113 §8.2.2"; private static readonly HttpContent SharedEmptyContent = new ByteArrayContent([]); - private HpackDecoder _hpack = new(); + // RFC 9113 §6.5.2: enforce the cumulative decoded header-list size (MAX_HEADER_LIST_SIZE) inside the + // HPACK decoder so a decompression bomb is rejected mid-decode, before the full list is materialized. + private HpackDecoder _hpack = CreateHpack(maxTotalHeaderSize); + + private static HpackDecoder CreateHpack(int maxHeaderListSize) + { + var hpack = new HpackDecoder(); + hpack.SetMaxHeaderListSize(maxHeaderListSize); + return hpack; + } public void SetMaxAllowedTableSize(int size) { @@ -25,7 +31,7 @@ public void SetMaxAllowedTableSize(int size) public void ResetHpack() { - _hpack = new HpackDecoder(); + _hpack = CreateHpack(maxTotalHeaderSize); } public HttpResponseMessage? DecodeHeaders(int streamId, bool endStream, StreamState state) @@ -37,6 +43,11 @@ public void ResetHpack() var response = new HttpResponseMessage(); AssembleResponse(headers, response, state); + if ((int)response.StatusCode < 200) + { + return response; + } + state.InitResponse(response); if (!endStream) @@ -61,7 +72,11 @@ public HttpResponseMessage DecodeHeadersForStreaming(int streamId, StreamState s var response = new HttpResponseMessage(); AssembleResponse(headers, response, state); - state.InitResponse(response); + if ((int)response.StatusCode >= 200) + { + state.InitResponse(response); + } + return response; } @@ -96,14 +111,14 @@ internal static void ValidateResponseHeaders(List headers) static h => h.Value, UppercaseSection, TokenSection, - FieldValueSection, + TokenSection, ConnectionSection); } private void ValidateHeaderSize(List headers, int streamId) { - var totalHeaderSize = 0; - + // Cumulative header-list size is enforced inside the HPACK decoder (MAX_HEADER_LIST_SIZE); here we + // only bound the size of any single header field (RFC 9113 §10.5.1). for (var i = 0; i < headers.Count; i++) { var headerSize = headers[i].Name.Length + headers[i].Value.Length; @@ -113,17 +128,7 @@ private void ValidateHeaderSize(List headers, int streamId) throw new HttpProtocolException( $"RFC 9113 §10.5.1: Single header field size {headerSize} bytes " + $"exceeds MaxHeaderSize limit ({maxHeaderSize} bytes) " + - $"on stream {streamId} — header '{headers[i].Name}'."); - } - - totalHeaderSize += headerSize; - - if (totalHeaderSize > maxTotalHeaderSize) - { - throw new HttpProtocolException( - $"RFC 9113 §10.5.1: Total header block size {totalHeaderSize} bytes " + - $"exceeds MaxTotalHeaderSize limit ({maxTotalHeaderSize} bytes) " + - $"on stream {streamId}."); + $"on stream {streamId} - header '{headers[i].Name}'."); } } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientEncoder.cs index 1a4a042be..47514cbef 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientEncoder.cs @@ -9,10 +9,17 @@ namespace TurboHTTP.Protocol.Syntax.Http2.Client; /// Stateful: maintains HPACK encoder and stream ID counter. /// One instance per connection. /// -internal sealed class Http2ClientEncoder(bool useHuffman = false, int maxFrameSize = 16 * 1024) +internal sealed class Http2ClientEncoder(bool useHuffman) { private HpackEncoder _hpack = new(useHuffman); - private int _maxFrameSize = maxFrameSize; + + /// + /// Maximum payload size for frames this client may send, in bytes. Starts at the RFC 9113 + /// default (16,384) and is raised only when the server advertises a larger + /// SETTINGS_MAX_FRAME_SIZE via . This is the peer's receive + /// limit - it is intentionally NOT driven by the client's own MaxFrameSize option. + /// + public int MaxFrameSize { get; private set; } = 16 * 1024; // Tracks MemoryPool rentals from the previous Encode() call so they can be // disposed once the caller has consumed the frame list (contract: callers consume @@ -76,25 +83,25 @@ internal byte[] EncodeToHpackBlock(HttpRequestMessage request) using var owner = MemoryPool.Shared.Rent(4096); var hpackWritable = owner.Memory.Span; var hpackBytesWritten = _hpack.Encode(_reusableHeaders, ref hpackWritable, useHuffman); - return owner.Memory[..hpackBytesWritten].ToArray(); // TEST ONLY: copy intentional — callers own the byte[] + return owner.Memory[..hpackBytesWritten].ToArray(); // TEST ONLY: copy intentional - callers own the byte[] } private void EncodeHeaders(List frames, int streamId, ReadOnlyMemory headerBlock, bool hasBody) { - if (headerBlock.Length <= _maxFrameSize) + if (headerBlock.Length <= MaxFrameSize) { frames.Add(new HeadersFrame(streamId, headerBlock, endStream: !hasBody, endHeaders: true)); return; } - // Fragmented header block — first chunk goes in HEADERS frame - frames.Add(new HeadersFrame(streamId, headerBlock[.._maxFrameSize], endStream: false, + // Fragmented header block - first chunk goes in HEADERS frame + frames.Add(new HeadersFrame(streamId, headerBlock[..MaxFrameSize], endStream: false, endHeaders: false)); - var pos = _maxFrameSize; + var pos = MaxFrameSize; while (pos < headerBlock.Length) { - var chunkSize = Math.Min(headerBlock.Length - pos, _maxFrameSize); + var chunkSize = Math.Min(headerBlock.Length - pos, MaxFrameSize); var isLast = pos + chunkSize >= headerBlock.Length; frames.Add(new ContinuationFrame(streamId, headerBlock[pos..(pos + chunkSize)], endHeaders: isLast)); @@ -152,7 +159,7 @@ public void ApplyServerSettings(IEnumerable<(SettingsParameter Key, uint Value)> switch (key) { case SettingsParameter.MaxFrameSize: - _maxFrameSize = (int)val; + MaxFrameSize = (int)val; break; case SettingsParameter.HeaderTableSize: _hpack.AcknowledgeTableSizeChange((int)val); diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs index 7e5ae2879..63dcc36a1 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs @@ -1,15 +1,21 @@ +using System.Buffers; +using System.Net; +using Akka.Actor; using Servus.Akka.Transport; using TurboHTTP.Client; using TurboHTTP.Internal; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.Multiplexed; -using TurboHTTP.Protocol.Multiplexed.Body; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http2.Client; +internal readonly record struct StreamBodyReadComplete(int StreamId, int BytesRead); +internal readonly record struct StreamBodyReadFailed(int StreamId, Exception Reason); + internal sealed class Http2ClientSessionManager { private readonly Http2ClientEncoderOptions _encoderOptions; @@ -20,45 +26,72 @@ internal sealed class Http2ClientSessionManager private readonly StreamTracker _tracker; private readonly FlowController _flow; private readonly StackStreamStatePool _statePool; - private readonly FrameDecoder _frameDecoder = new(); + private readonly FrameDecoder _frameDecoder; private readonly Http2ClientDecoder _responseDecoder; private readonly Http2ClientEncoder _requestEncoder; private readonly Dictionary _correlationMap = new(); private readonly Dictionary _streams = new(); + private readonly Dictionary _activeBodyStreams = new(); + private readonly Dictionary> _activeBodyBuffers = new(); private bool _prefaceSent; private bool _awaitingPingAck; private long _pingSentTimestamp; + private static readonly byte[] RttPingPayload = "RTTPROBE"u8.ToArray(); + public bool CanOpenStream => _tracker.CanOpenStream(); public bool GoAwayReceived => _flow.GoAwayReceived; public int GoAwayLastStreamId { get; private set; } - public bool HasInFlightRequests => _correlationMap.Count > 0; + public bool HasInFlightRequests => _correlationMap.Count > 0 || _streams.Count > 0; public bool HasActiveStreams => _streams.Count > 0; public RequestEndpoint Endpoint { get; private set; } + /// TEST ONLY: latest measured min-RTT, or zero if scaling disabled / no sample. + internal TimeSpan MinRttForTest => _flow.MinRtt; + + /// True if the PING carries the measurement sentinel payload. + internal static bool IsRttPing(PingFrame ping) => + ping.Data.Span.SequenceEqual(RttPingPayload); + public Http2ClientSessionManager( - Http2ClientEncoderOptions encoderOptions, - Http2ClientDecoderOptions decoderOptions, TurboClientOptions options, - IClientStageOperations ops) + IClientStageOperations ops, + TimeProvider? timeProvider = null) { - _encoderOptions = encoderOptions; - _decoderOptions = decoderOptions; + _encoderOptions = options.ToHttp2EncoderOptions(); + _decoderOptions = options.ToHttp2DecoderOptions(); _options = options; _ops = ops; - _tracker = new StreamTracker(1, decoderOptions.MaxConcurrentStreams); + var clock = timeProvider ?? TimeProvider.System; + _tracker = new StreamTracker(1, _decoderOptions.MaxConcurrentStreams); + + WindowScaler? scaler = null; + if (_decoderOptions.EnableAdaptiveWindowScaling) + { + scaler = new WindowScaler( + _decoderOptions.MaxStreamWindowSize, + _decoderOptions.WindowScaleThresholdMultiplier); + } + _flow = new FlowController( - decoderOptions.InitialConnectionWindowSize, - decoderOptions.InitialStreamWindowSize); - _requestEncoder = new Http2ClientEncoder(useHuffman: true, maxFrameSize: encoderOptions.MaxFrameSize); + _decoderOptions.InitialConnectionWindowSize, + _decoderOptions.InitialStreamWindowSize, + scaler, + clock); + // Outgoing frame size starts at the RFC 9113 default (16,384) and is raised only when the + // server advertises a larger SETTINGS_MAX_FRAME_SIZE. The client's own MaxFrameSize option + // is a receive-side advertisement (sent in the preface), not a send-side limit. + _requestEncoder = new Http2ClientEncoder(useHuffman: true); var poolCapacity = Math.Min( _tracker.MaxConcurrentStreams > 0 ? _tracker.MaxConcurrentStreams : 100, 1000); _statePool = new StackStreamStatePool(poolCapacity, () => new StreamState()); - _responseDecoder = new Http2ClientDecoder(); - _responseDecoder.SetMaxAllowedTableSize(encoderOptions.HeaderTableSize); + _responseDecoder = new Http2ClientDecoder(_decoderOptions.MaxHeaderSize, _decoderOptions.MaxHeaderListSize); + _responseDecoder.SetMaxAllowedTableSize(_encoderOptions.HeaderTableSize); + // RFC 9113 §4.2: enforce the MAX_FRAME_SIZE we advertise in the preface on inbound frames. + _frameDecoder = new FrameDecoder(_encoderOptions.MaxFrameSize); } public TransportData? TryBuildPreface() @@ -70,6 +103,7 @@ public Http2ClientSessionManager( _prefaceSent = true; var (prefaceOwner, prefaceLength) = PrefaceBuilder.Build( + _decoderOptions.InitialStreamWindowSize, _decoderOptions.InitialConnectionWindowSize, _encoderOptions.HeaderTableSize, _encoderOptions.MaxFrameSize); @@ -77,7 +111,7 @@ public Http2ClientSessionManager( prefaceOwner.Memory.Span[..prefaceLength].CopyTo(prefaceBuf.FullMemory.Span); prefaceOwner.Dispose(); prefaceBuf.Length = prefaceLength; - return new TransportData(prefaceBuf); + return TransportData.Rent(prefaceBuf); } public void EncodeRequest(HttpRequestMessage request) @@ -87,7 +121,7 @@ public void EncodeRequest(HttpRequestMessage request) if (GoAwayReceived) { Tracing.For("Protocol").Warning(this, - "HTTP/2: RFC 9113 §6.8 — GOAWAY received; dropping new request (stream {0})", streamId); + "HTTP/2: RFC 9113 §6.8 - GOAWAY received; dropping new request (stream {0})", streamId); request.Fail(new HttpRequestException("HTTP/2 GOAWAY received.")); return; } @@ -144,7 +178,7 @@ public void EncodeRequest(HttpRequestMessage request) } buf.Length = totalSize; - _ops.OnOutbound(new TransportData(buf)); + _ops.OnOutbound(TransportData.Rent(buf)); if (request.Content is null) { @@ -159,14 +193,107 @@ public void EncodeRequest(HttpRequestMessage request) var contentLength = request.Content?.Headers.ContentLength; var bodyStream = request.Content?.ReadAsStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength); - if (encoder is null) + + // Fast path A: MemoryStream with an accessible backing buffer - slice directly into DATA + // frames without allocating a MemoryPool chunk per read. The backing byte[] is kept alive + // by HttpRequestMessage.Content for the duration of the request, so referencing its memory + // here is safe. TryGetBuffer succeeds only for streams created with a publicly visible + // buffer (e.g. new MemoryStream(), new MemoryStream(capacity)) - non-visible streams + // (ByteArrayContent internal, MemoryStream(buf, false)) fall through to the slow path. + if (bodyStream is MemoryStream ms && ms.TryGetBuffer(out var segment)) + { + var pos = (int)ms.Position; + var available = segment.Count - pos; + if (available > 0) + { + EmitBodyDirect(streamId, state, segment.AsMemory(pos, available)); + return; + } + } + + // Fast path B: Content with a known length within the buffer threshold - copy body + // directly into an ArrayPool-rented buffer via CopyTo, then emit frames without spinning + // up the async encoder pipeline (no background Task, no actor messages, no per-chunk + // MemoryPool.Rent). Handles ByteArrayContent, StringContent, ReadOnlyMemoryContent and + // any other sync-serializable content. Falls through if the content does not support + // synchronous serialization (CopyTo throws NotSupportedException). + if (contentLength is > 0 and { } knownLength + && knownLength <= _options.Http2.MaxBufferedRequestBodySize + && TrySerializeBodyDirect(request.Content!, streamId, state, (int)knownLength)) { return; } - state.InitBodyEncoder(encoder); - state.StartBodyEncoder(bodyStream!, streamId, _ops.StageActor); + state.MarkBodyDrainActive(); + StartStreamBodyDrain(streamId, bodyStream!, contentLength); + } + + private void EmitBodyDirect(int streamId, StreamState state, Memory body) + { + var maxFrame = _requestEncoder.MaxFrameSize; + var window = (int)Math.Min(_flow.GetSendWindow(streamId), int.MaxValue); + var sent = 0; + + while (sent < body.Length && window > 0) + { + var chunkLen = Math.Min(Math.Min(maxFrame, window), body.Length - sent); + var endStream = sent + chunkLen >= body.Length; + EmitFrame(new DataFrame(streamId, body.Slice(sent, chunkLen), endStream)); + _flow.OnDataSent(streamId, chunkLen); + window -= chunkLen; + sent += chunkLen; + } + + if (sent >= body.Length) + { + // All data sent inline - mark complete and release stream state. + CloseStream(streamId); + + if (state.IsRemoteClosed) + { + _streams.Remove(streamId); + state.Reset(); + _statePool.Return(state); + } + + return; + } + + // Window exhausted before all data sent: buffer the remainder. + // A copy into a pooled buffer is required here because the window-drain path + // (DrainOutboundBuffer) expects IMemoryOwner-backed chunks. + var remaining = body.Length - sent; + var owner = MemoryPool.Shared.Rent(remaining); + body.Slice(sent, remaining).CopyTo(owner.Memory); + state.EnqueueBodyChunk(new StreamBodyChunk(owner, remaining)); + state.MarkBodyDrainComplete(); + } + + private bool TrySerializeBodyDirect(HttpContent content, int streamId, StreamState state, int bodyLength) + { + var pool = ArrayPool.Shared; + var bodyArray = pool.Rent(bodyLength); + try + { + using var ms = new MemoryStream(bodyArray, 0, bodyLength, writable: true); + content.CopyTo(ms, null, CancellationToken.None); + } + catch (NotSupportedException) + { + // Content does not support synchronous serialization (CopyTo delegates to the + // protected SerializeToStream which throws NotSupportedException for async-only + // content). Fall back to the async encoder pipeline. + pool.Return(bodyArray); + return false; + } + + EmitBodyDirect(streamId, state, new Memory(bodyArray, 0, bodyLength)); + + // The array may be returned now: EmitBodyDirect copies all data into TransportBuffers + // (via EmitFrame → DataFrame.WriteTo) or into a MemoryPool-owned copy for the + // window-exhausted path before returning, so bodyArray is no longer referenced. + pool.Return(bodyArray); + return true; } public IReadOnlyList DecodeFrames(TransportBuffer buffer) @@ -195,7 +322,7 @@ public void ProcessFrame(Http2Frame frame) break; case RstStreamFrame rst: - CloseStream(rst.StreamId); + HandleRstStream(rst); break; case WindowUpdateFrame win: @@ -225,6 +352,15 @@ public void SendKeepAlivePing() EmitFrame(new PingFrame(data, isAck: false)); } + private void MaybeSendMeasurementPing() + { + if (_flow.ShouldSendMeasurementPing()) + { + _flow.OnMeasurementPingSent(); + EmitFrame(new PingFrame(RttPingPayload, isAck: false)); + } + } + public bool IsKeepAliveTimedOut(TimeSpan timeout) { if (!_awaitingPingAck) @@ -236,6 +372,31 @@ public bool IsKeepAliveTimedOut(TimeSpan timeout) return elapsed >= (long)timeout.TotalMilliseconds; } + public bool TryCancelStream(HttpRequestMessage request) + { + var streamId = -1; + foreach (var (id, req) in _correlationMap) + { + if (ReferenceEquals(req, request)) + { + streamId = id; + break; + } + } + + if (streamId < 0) + { + return false; + } + + EmitFrame(new RstStreamFrame(streamId, Http2ErrorCode.Cancel)); + _correlationMap.Remove(streamId); + request.Fail(new OperationCanceledException("Request cancelled by caller.")); + CloseStream(streamId); + + return true; + } + public IReadOnlyDictionary GetCorrelationMap() { return _correlationMap; @@ -269,9 +430,10 @@ public void ResetConnectionState() public void Cleanup() { - foreach (var (_, state) in _streams) + foreach (var (streamId, state) in _streams) { state.AbortBody(); + CleanupBodyDrain(streamId); } ReleaseAllStreamState(); @@ -279,7 +441,9 @@ public void Cleanup() private void EmitDataFrames(int streamId, ReadOnlyMemory data) { - var maxFrame = _encoderOptions.MaxFrameSize; + // Split DATA frames by the server's advertised MAX_FRAME_SIZE (tracked by the encoder), + // not the client's own receive-side option. + var maxFrame = _requestEncoder.MaxFrameSize; var remaining = data; while (remaining.Length > maxFrame) { @@ -295,11 +459,17 @@ private void EmitDataFrames(int streamId, ReadOnlyMemory data) private void EmitFrame(Http2Frame frame) { + if (frame is DataFrame d) + { + Tracing.For("Protocol").Trace(this, "HTTP/2: DATA out (stream={0}, len={1}, endStream={2})", + d.StreamId, d.Data.Length, d.EndStream); + } + var buf = TransportBuffer.Rent(frame.SerializedSize); var span = buf.FullMemory.Span; frame.WriteTo(ref span); buf.Length = frame.SerializedSize; - _ops.OnOutbound(new TransportData(buf)); + _ops.OnOutbound(TransportData.Rent(buf)); } private void HandleSettings(SettingsFrame frame) @@ -322,12 +492,15 @@ private void HandleSettings(SettingsFrame frame) private void ProcessDataFrame(DataFrame data) { + Tracing.For("Protocol").Trace(this, "HTTP/2: DATA in (stream={0}, len={1}, endStream={2})", + data.StreamId, data.Data.Length, data.EndStream); + var result = _flow.OnInboundData(data.StreamId, data.Data.Length); if (result.IsConnectionViolation) { Tracing.For("Protocol").Info(this, - "HTTP/2: RFC 9113 §6.9 — connection flow control window exceeded. Triggering reconnect"); + "HTTP/2: RFC 9113 §6.9 - connection flow control window exceeded. Triggering reconnect"); _ops.OnOutbound(new DisconnectTransport(DisconnectReason.Error)); return; } @@ -335,7 +508,7 @@ private void ProcessDataFrame(DataFrame data) if (result.IsStreamViolation) { Tracing.For("Protocol").Info(this, - "HTTP/2: RFC 9113 §6.9 — stream {0} flow control window exceeded. Triggering reconnect", data.StreamId); + "HTTP/2: RFC 9113 §6.9 - stream {0} flow control window exceeded. Triggering reconnect", data.StreamId); _ops.OnOutbound(new DisconnectTransport(DisconnectReason.Error)); return; } @@ -350,13 +523,14 @@ private void ProcessDataFrame(DataFrame data) EmitFrame(new WindowUpdateFrame(streamUpdate.StreamId, streamUpdate.Increment)); } + MaybeSendMeasurementPing(); + HandleData(data); if (data.EndStream) { var hasActiveBodyEncoder = _streams.TryGetValue(data.StreamId, out var state) - && state.HasBodyEncoder - && !state.IsBodyEncoderComplete; + && state is { HasBodyDrain: true, IsBodyDrainComplete: false }; if (!hasActiveBodyEncoder) { CloseStream(data.StreamId); @@ -368,6 +542,12 @@ private void HandlePing(PingFrame ping) { if (ping.IsAck) { + if (IsRttPing(ping)) + { + _flow.OnMeasurementPingAck(); + return; + } + _awaitingPingAck = false; return; } @@ -384,17 +564,30 @@ private void HandleGoAway(GoAwayFrame goAway) _flow.OnGoAway(); GoAwayLastStreamId = goAway.LastStreamId; Tracing.For("Protocol").Info(this, - "HTTP/2: GOAWAY received from {0} — LastStreamId={1}, ErrorCode={2}. Reconnecting", Endpoint.Host, + "HTTP/2: GOAWAY received from {0} - LastStreamId={1}, ErrorCode={2}. Reconnecting", Endpoint.Host, goAway.LastStreamId, goAway.ErrorCode); } + private void HandleRstStream(RstStreamFrame rst) + { + if (_correlationMap.Remove(rst.StreamId, out var request)) + { + request.Fail(new HttpRequestException( + string.Concat("HTTP/2 stream ", rst.StreamId.ToString(), " was reset by the server (error code ", + rst.ErrorCode.ToString(), ")."))); + } + + CloseStream(rst.StreamId); + } + private void CloseStream(int streamId) { - if (_streams.TryGetValue(streamId, out var state) && state.HasBodyDecoder) + if (_streams.TryGetValue(streamId, out var state) && state.HasBodyReader) { state.AbortBody(); } + CleanupBodyDrain(streamId); _tracker.OnStreamClosed(streamId); _flow.RemoveStreamSendWindow(streamId); @@ -413,7 +606,7 @@ private void HandleHeaders(HeadersFrame frame) _streams[frame.StreamId] = state; } - state.AppendHeader(frame.HeaderBlockFragment.Span); + state.AppendHeader(frame.HeaderBlockFragment.Span, _decoderOptions.MaxHeaderListSize); if (!frame.EndHeaders) { @@ -427,12 +620,12 @@ private void HandleContinuation(ContinuationFrame frame) { if (!_streams.TryGetValue(frame.StreamId, out var state)) { - Tracing.For("Protocol").Warning(this, "HTTP/2: Received CONTINUATION for unknown stream {0} — dropping", + Tracing.For("Protocol").Warning(this, "HTTP/2: Received CONTINUATION for unknown stream {0} - dropping", frame.StreamId); return; } - state.AppendHeader(frame.HeaderBlockFragment.Span); + state.AppendHeader(frame.HeaderBlockFragment.Span, _decoderOptions.MaxHeaderListSize); if (frame.EndHeaders) { @@ -444,14 +637,14 @@ private void HandleData(DataFrame frame) { if (!_streams.TryGetValue(frame.StreamId, out var state)) { - Tracing.For("Protocol").Warning(this, "HTTP/2: Received DATA for unknown stream {0} — dropping", + Tracing.For("Protocol").Warning(this, "HTTP/2: Received DATA for unknown stream {0} - dropping", frame.StreamId); return; } - if (!state.HasBodyDecoder) + if (!state.HasBodyReader) { - Tracing.For("Protocol").Warning(this, "HTTP/2: Received DATA before HEADERS on stream {0} — dropping", + Tracing.For("Protocol").Warning(this, "HTTP/2: Received DATA before HEADERS on stream {0} - dropping", frame.StreamId); return; } @@ -460,10 +653,10 @@ private void HandleData(DataFrame frame) if (frame.EndStream) { - state.DetachBodyDecoder(); + state.DetachBodyReader(); state.MarkRemoteClosed(); - if (!state.HasBodyEncoder || state.IsBodyEncoderComplete) + if (!state.HasBodyDrain || state.IsBodyDrainComplete) { _streams.Remove(frame.StreamId); state.Reset(); @@ -476,7 +669,7 @@ private void DecodeHeaders(int streamId, bool endStream) { if (!_streams.TryGetValue(streamId, out var state)) { - Tracing.For("Protocol").Warning(this, "HTTP/2: DecodeHeaders called for unknown stream {0} — dropping", + Tracing.For("Protocol").Warning(this, "HTTP/2: DecodeHeaders called for unknown stream {0} - dropping", streamId); return; } @@ -484,10 +677,11 @@ private void DecodeHeaders(int streamId, bool endStream) if (state.HasResponse) { _responseDecoder.DecodeTrailers(state); + state.ClearHeaderBuffer(); if (endStream) { _streams.Remove(streamId); - state.DetachBodyDecoder(); + state.DetachBodyReader(); state.Reset(); _statePool.Return(state); } @@ -498,11 +692,32 @@ private void DecodeHeaders(int streamId, bool endStream) if (endStream) { var response = _responseDecoder.DecodeHeaders(streamId, true, state); + state.ClearHeaderBuffer(); if (response is null) { return; } + if ((int)response.StatusCode < 200) + { + if (_correlationMap.TryGetValue(streamId, out var interimReq)) + { + response.RequestMessage = interimReq; + } + + _ops.OnResponse(response); + + if (endStream) + { + _correlationMap.Remove(streamId); + _streams.Remove(streamId); + state.Reset(); + _statePool.Return(state); + } + + return; + } + if (_correlationMap.Remove(streamId, out var req)) { response.RequestMessage = req; @@ -523,7 +738,16 @@ private void DecodeHeaders(int streamId, bool endStream) } var streamingResponse = _responseDecoder.DecodeHeadersForStreaming(streamId, state); - state.InitBodyDecoder(BodyDecoderFactory.Create(streaming: true)); + state.ClearHeaderBuffer(); + + if ((int)streamingResponse.StatusCode < 200) + { + return; + } + + var queued = new QueuedBodyReader(capacity: 8); + queued.Reset(); + state.InitBodyReader(queued); var bodyStream = state.GetBodyStream(); streamingResponse.Content = new StreamContent(bodyStream); state.ApplyContentHeadersTo(streamingResponse.Content); @@ -533,6 +757,16 @@ private void DecodeHeaders(int streamId, bool endStream) streamingResponse.RequestMessage = request; } + // RFC 9113 §8.1.1: a stream ending before the declared Content-Length is malformed. + // Record the expectation so END_STREAM faults the body instead of completing it. + // HEAD/204/304 legitimately carry Content-Length without a body. + var noBodyExpected = request?.Method == HttpMethod.Head + || streamingResponse.StatusCode is HttpStatusCode.NoContent or HttpStatusCode.NotModified; + if (!noBodyExpected) + { + state.ExpectedBodyLength = streamingResponse.Content.Headers.ContentLength; + } + var partialResult = PartialContentValidator.Validate(streamingResponse); if (!partialResult.IsValid) { @@ -546,73 +780,76 @@ public void OnBodyMessage(object msg) { switch (msg) { - case StreamBodyChunk chunk: - HandleOutboundBodyChunk(chunk); + case StreamBodyReadComplete read: + HandleStreamBodyRead(read); break; - case StreamBodyComplete complete: - HandleOutboundBodyComplete(complete.StreamId); - break; - - case StreamBodyFailed(var failedStreamId, var exception): + case StreamBodyReadFailed failed: Tracing.For("Protocol").Warning(this, - "HTTP/2: Body encoding failed for stream {0}: {1}", failedStreamId, exception.Message); - EmitFrame(new RstStreamFrame(failedStreamId, Http2ErrorCode.InternalError)); - CloseStream(failedStreamId); + "HTTP/2: Body drain failed for stream {0}: {1}", failed.StreamId, failed.Reason.Message); + EmitFrame(new RstStreamFrame(failed.StreamId, Http2ErrorCode.InternalError)); + CleanupBodyDrain(failed.StreamId); + CloseStream(failed.StreamId); break; } } - private void HandleOutboundBodyChunk(StreamBodyChunk chunk) + private void HandleStreamBodyRead(StreamBodyReadComplete read) { - var streamId = chunk.StreamId; - if (!_streams.TryGetValue(streamId, out var state)) + if (!_streams.TryGetValue(read.StreamId, out var state)) { - chunk.Owner.Dispose(); + CleanupBodyDrain(read.StreamId); return; } - var window = (int)Math.Min(_flow.GetSendWindow(streamId), int.MaxValue); - if (window >= chunk.Length) + state.IsBodyReadPending = false; + + if (read.BytesRead == 0) { - EmitDataFrames(streamId, chunk.Data); - _flow.OnDataSent(streamId, chunk.Length); - chunk.Owner.Dispose(); + EmitFrame(new DataFrame(read.StreamId, ReadOnlyMemory.Empty, endStream: true)); + state.MarkBodyDrainComplete(); + CleanupBodyDrain(read.StreamId); + + if (state.IsRemoteClosed) + { + _streams.Remove(read.StreamId); + state.Reset(); + _statePool.Return(state); + } + return; } - if (window > 0) + if (!_activeBodyBuffers.TryGetValue(read.StreamId, out var buffer)) { - EmitDataFrames(streamId, chunk.Data[..window]); - _flow.OnDataSent(streamId, window); - var remainder = chunk with { Offset = chunk.Offset + window, Length = chunk.Length - window }; - state.EnqueueBodyChunk(remainder); + CleanupBodyDrain(read.StreamId); return; } - state.EnqueueBodyChunk(chunk); - } + var data = buffer.Memory[..read.BytesRead]; + var window = (int)Math.Min(_flow.GetSendWindow(read.StreamId), int.MaxValue); - private void HandleOutboundBodyComplete(int streamId) - { - if (!_streams.TryGetValue(streamId, out var state)) + if (window >= read.BytesRead) { - return; + EmitDataFrames(read.StreamId, data); + _flow.OnDataSent(read.StreamId, read.BytesRead); + ReadNextBodyChunk(read.StreamId); } - - state.MarkBodyEncoderComplete(); - - if (!state.HasPendingOutbound) + else if (window > 0) { - EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: true)); - CloseStream(streamId); + EmitDataFrames(read.StreamId, data[..window]); + _flow.OnDataSent(read.StreamId, window); - if (state.IsRemoteClosed) - { - _streams.Remove(streamId); - state.Reset(); - _statePool.Return(state); - } + var remaining = read.BytesRead - window; + var owner = MemoryPool.Shared.Rent(remaining); + data[window..].CopyTo(owner.Memory); + state.EnqueueBodyChunk(new StreamBodyChunk(owner, remaining)); + } + else + { + var owner = MemoryPool.Shared.Rent(read.BytesRead); + data[..read.BytesRead].CopyTo(owner.Memory); + state.EnqueueBodyChunk(new StreamBodyChunk(owner, read.BytesRead)); } } @@ -623,7 +860,7 @@ private void DrainOutboundBuffer(int streamId) return; } - while (state.PeekBodyChunk() is { } next) + while (state.PeekBodyChunk() is not null) { var window = (int)Math.Min(_flow.GetSendWindow(streamId), int.MaxValue); if (window <= 0) @@ -648,7 +885,7 @@ private void DrainOutboundBuffer(int streamId) } } - if (state is { HasPendingOutbound: false, IsBodyEncoderComplete: true }) + if (state is { HasPendingOutbound: false, IsBodyDrainComplete: true }) { EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: true)); CloseStream(streamId); @@ -660,6 +897,10 @@ private void DrainOutboundBuffer(int streamId) _statePool.Return(state); } } + else if (!state.HasPendingOutbound && state.HasBodyDrain && !state.IsBodyDrainComplete && !state.IsBodyReadPending) + { + ReadNextBodyChunk(streamId); + } } private void HandleWindowUpdate(WindowUpdateFrame frame) @@ -678,4 +919,45 @@ private void HandleWindowUpdate(WindowUpdateFrame frame) DrainOutboundBuffer(frame.StreamId); } } + + private void StartStreamBodyDrain(int streamId, Stream bodyStream, long? contentLength = null) + { + _activeBodyStreams[streamId] = bodyStream; + var maxSize = Math.Min(_options.RequestBodyChunkSize, _requestEncoder.MaxFrameSize); + var bufferSize = contentLength is > 0 and <= int.MaxValue + ? (int)Math.Min(contentLength.Value, maxSize) + : maxSize; + var buffer = MemoryPool.Shared.Rent(Math.Max(bufferSize, 256)); + _activeBodyBuffers[streamId] = buffer; + ReadNextBodyChunk(streamId); + } + + private void ReadNextBodyChunk(int streamId) + { + if (!_activeBodyStreams.TryGetValue(streamId, out var stream) || + !_activeBodyBuffers.TryGetValue(streamId, out var buffer)) + { + return; + } + + if (_streams.TryGetValue(streamId, out var state)) + { + state.IsBodyReadPending = true; + } + + stream.ReadAsync(buffer.Memory).AsTask().PipeTo( + _ops.StageActor, + success: bytesRead => new StreamBodyReadComplete(streamId, bytesRead), + failure: ex => new StreamBodyReadFailed(streamId, ex)); + } + + private void CleanupBodyDrain(int streamId) + { + if (_activeBodyBuffers.Remove(streamId, out var buffer)) + { + buffer.Dispose(); + } + + _activeBodyStreams.Remove(streamId); + } } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs index fc992ff63..243e20a3f 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs @@ -2,9 +2,8 @@ using TurboHTTP.Client; using TurboHTTP.Internal; using TurboHTTP.Protocol.Multiplexed; -using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http2.Client; @@ -29,33 +28,12 @@ internal sealed class Http2ClientStateMachine : IClientStateMachine public RequestEndpoint Endpoint => _clientSession.Endpoint; public int ReconnectBufferCount => _reconnect.BufferedCount; - public Http2ClientStateMachine(TurboClientOptions options, IClientStageOperations ops) + public Http2ClientStateMachine(TurboClientOptions options, IClientStageOperations ops, TimeProvider? timeProvider = null) { _options = options; _ops = ops; - - var shared = SharedHttpOptions.Default with - { - MaxBufferedBodySize = options.MaxBufferedBodySize, - MaxStreamedBodySize = options.MaxStreamedBodySize, - }; - - var encoderOpts = new Http2ClientEncoderOptions - { - HeaderTableSize = options.Http2.HeaderTableSize, - Shared = shared, - }; - - var decoderOpts = new Http2ClientDecoderOptions - { - MaxConcurrentStreams = options.Http2.MaxConcurrentStreams, - InitialConnectionWindowSize = options.Http2.InitialConnectionWindowSize, - InitialStreamWindowSize = options.Http2.InitialStreamWindowSize, - Shared = shared, - }; - - _clientSession = new Http2ClientSessionManager(encoderOpts, decoderOpts, options, ops); - _reconnect = new ReconnectionManager(options.Http2.MaxReconnectAttempts); + _clientSession = new Http2ClientSessionManager(options, ops, timeProvider); + _reconnect = new ReconnectionManager(options.Http2.MaxReconnectAttempts, options.Http2.MaxReconnectBufferSize); } public void PreStart() @@ -92,10 +70,25 @@ public void DecodeServerData(ITransportInbound data) return; } - var frames = _clientSession.DecodeFrames(buffer); - for (var i = 0; i < frames.Count; i++) + int frameCount; + try { - _clientSession.ProcessFrame(frames[i]); + var frames = _clientSession.DecodeFrames(buffer); + frameCount = frames.Count; + for (var i = 0; i < frames.Count; i++) + { + _clientSession.ProcessFrame(frames[i]); + } + } + catch (HttpProtocolException ex) + { + // RFC 9113 §5.4.1: a connection-fatal protocol error leaves the decoder desynchronized. + // Drop the connection instead of swallowing and continuing; the resulting TransportDisconnected + // routes through OnConnectionLost, which replays idempotent in-flight requests and fails the rest. + Tracing.For("Protocol").Info(this, + "HTTP/2: connection protocol error - disconnecting: {0}", ex.Message); + _ops.OnOutbound(new DisconnectTransport(DisconnectReason.Error)); + return; } if (_clientSession is { GoAwayReceived: true, HasInFlightRequests: true }) @@ -104,7 +97,7 @@ public void DecodeServerData(ITransportInbound data) return; } - if (frames.Count > 0) + if (frameCount > 0) { ResetKeepAliveTimer(); } @@ -125,30 +118,44 @@ public void OnTimerFired(string name) switch (name) { case KeepAlivePingTimerKey: + { + var policy = _options.Http2.KeepAlivePingPolicy; + if (policy == HttpKeepAlivePingPolicy.WithActiveRequests && !_clientSession.HasInFlightRequests) { - var policy = _options.Http2.KeepAlivePingPolicy; - if (policy == HttpKeepAlivePingPolicy.WithActiveRequests && !_clientSession.HasInFlightRequests) - { - return; - } - - _clientSession.SendKeepAlivePing(); - ScheduleKeepAlivePingTimeout(); - break; + return; } + + _clientSession.SendKeepAlivePing(); + ScheduleKeepAlivePingTimeout(); + break; + } case KeepAlivePingTimeoutKey: + { + if (_clientSession.IsKeepAliveTimedOut(_options.Http2.KeepAlivePingTimeout)) { - if (_clientSession.IsKeepAliveTimedOut(_options.Http2.KeepAlivePingTimeout)) + Tracing.For("Protocol").Info(this, "HTTP/2: Keep-alive PING timeout - closing connection"); + if (_clientSession.HasInFlightRequests) { - Tracing.For("Protocol").Info(this, "HTTP/2: Keep-alive PING timeout — closing connection"); - if (_clientSession.HasInFlightRequests) - { - OnConnectionLost(lastStreamId: 0); - } + OnConnectionLost(lastStreamId: 0); } - - break; } + + break; + } + } + } + + public void OnRequestCancelled(HttpRequestMessage request) + { + if (IsReconnecting) + { + request.Fail(new OperationCanceledException("Request cancelled by caller.")); + return; + } + + if (_clientSession.TryCancelStream(request)) + { + Tracing.For("Protocol").Debug(this, "HTTP/2: cancelled request, sent RST_STREAM"); } } @@ -158,6 +165,7 @@ public void OnTimerFired(string name) private void OnConnectionLost(int lastStreamId) { + Tracing.For("Protocol").Info(this, "HTTP/2: connection lost (lastStreamId={0}, inFlight={1})", lastStreamId, _clientSession.HasInFlightRequests); var replayable = ClassifyStreamsForReplay(lastStreamId); _reconnect.OnConnectionLost(replayable); @@ -212,6 +220,7 @@ private static bool IsIdempotentMethod(HttpMethod method) private void OnConnectionRestored() { + Tracing.For("Protocol").Info(this, "HTTP/2: connection restored"); var preface = _clientSession.TryBuildPreface(); if (preface is not null) { diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs b/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs index 5eb316cd6..7daa08f0a 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs @@ -9,10 +9,22 @@ internal sealed class FlowController : IFlowController private readonly Dictionary _pendingStreamIncrements = new(); private int _windowUpdateThreshold; - private int _recvConnectionWindow; private int _initialRecvStreamWindow; + private readonly WindowScaler? _scaler; + private readonly TimeProvider? _clock; + private readonly RttEstimator? _rtt; + private readonly Dictionary _deliveredSinceSample = new(); + private readonly Dictionary _lastSampleTimestamp = new(); - public int RecvConnectionWindow => _recvConnectionWindow; + private static readonly TimeSpan MeasurementPingInterval = TimeSpan.FromMilliseconds(100); + + public int RecvConnectionWindow { get; private set; } + + /// Current per-stream receive window size (grows under adaptive scaling). + public int CurrentStreamWindow => _initialRecvStreamWindow; + + /// Latest measured min-RTT. Zero = unknown / scaling disabled. + public TimeSpan MinRtt => _rtt?.MinRtt ?? TimeSpan.Zero; private long _connectionSendWindow; private long _initialSendStreamWindow; @@ -21,20 +33,37 @@ internal sealed class FlowController : IFlowController public FlowController( int connectionWindowSize, int streamWindowSize, - long initialConnectionSendWindow = 65535, - long initialStreamSendWindow = 65535) + WindowScaler? scaler = null, + TimeProvider? clock = null) { - _recvConnectionWindow = connectionWindowSize; + RecvConnectionWindow = connectionWindowSize; _initialRecvStreamWindow = streamWindowSize; - _connectionSendWindow = initialConnectionSendWindow; - _initialSendStreamWindow = initialStreamSendWindow; + _connectionSendWindow = 65535; + _initialSendStreamWindow = 65535; + _scaler = scaler; + _clock = clock; - const int minWindowUpdateThreshold = 8_192; - _windowUpdateThreshold = Math.Max(minWindowUpdateThreshold, streamWindowSize / 2); + if (_scaler is not null && _clock is not null) + { + _rtt = new RttEstimator(_clock, MeasurementPingInterval); + } + + const int minWindowUpdateThreshold = 8 * 1024; + _windowUpdateThreshold = Math.Max(minWindowUpdateThreshold, streamWindowSize / 4); } public bool GoAwayReceived { get; private set; } + /// True when a measurement PING is due (scaling on, window below cap, estimator ready). + public bool ShouldSendMeasurementPing() => + _rtt is not null && _scaler is not null + && _initialRecvStreamWindow < _scaler.MaxWindow + && _rtt.ShouldSendPing(); + + public void OnMeasurementPingSent() => _rtt?.OnPingSent(); + + public void OnMeasurementPingAck() => _rtt?.OnPingAck(); + public long GetSendWindow(int streamId) { var streamWindow = _streamSendWindows.GetValueOrDefault(streamId, _initialSendStreamWindow); @@ -44,33 +73,47 @@ public long GetSendWindow(int streamId) public void OnDataSent(int streamId, int length) { _connectionSendWindow -= length; - if (_streamSendWindows.TryGetValue(streamId, out var current)) - { - _streamSendWindows[streamId] = current - length; - } + _streamSendWindows.TryAdd(streamId, _initialSendStreamWindow); + _streamSendWindows[streamId] -= length; } public void OnSendWindowUpdate(int streamId, int increment) { + const long maxWindow = int.MaxValue; + if (streamId == 0) { - _connectionSendWindow += increment; + var updated = _connectionSendWindow + increment; + if (updated > maxWindow) + { + throw new HttpProtocolException( + "RFC 9113 §6.9.1: WINDOW_UPDATE would exceed maximum flow-control window size."); + } + + _connectionSendWindow = updated; } else { var current = _streamSendWindows.GetValueOrDefault(streamId, _initialSendStreamWindow); - _streamSendWindows[streamId] = current + increment; + var updated = current + increment; + if (updated > maxWindow) + { + throw new HttpProtocolException( + "RFC 9113 §6.9.1: WINDOW_UPDATE would exceed maximum flow-control window size."); + } + + _streamSendWindows[streamId] = updated; } } public FlowControlResult OnInboundData(int streamId, int dataLength) { - _recvConnectionWindow -= dataLength; + RecvConnectionWindow -= dataLength; _recvStreamWindows.TryAdd(streamId, _initialRecvStreamWindow); _recvStreamWindows[streamId] -= dataLength; - if (_recvConnectionWindow < 0) + if (RecvConnectionWindow < 0) { return new FlowControlResult { Success = false, IsConnectionViolation = true }; } @@ -94,10 +137,16 @@ public FlowControlResult OnInboundData(int streamId, int dataLength) _pendingStreamIncrements.TryAdd(streamId, 0); _pendingStreamIncrements[streamId] += dataLength; + if (_scaler is not null) + { + _deliveredSinceSample.TryAdd(streamId, 0); + _deliveredSinceSample[streamId] += dataLength; + } + if (_pendingConnIncrement >= _windowUpdateThreshold) { var increment = _pendingConnIncrement; - _recvConnectionWindow += increment; + RecvConnectionWindow += increment; connUpdate = new WindowUpdateSignal(0, increment); _pendingConnIncrement = 0; } @@ -105,6 +154,27 @@ public FlowControlResult OnInboundData(int streamId, int dataLength) if (_pendingStreamIncrements[streamId] >= _windowUpdateThreshold) { var increment = _pendingStreamIncrements[streamId]; + + if (_scaler is not null && _clock is not null && MinRtt > TimeSpan.Zero) + { + var nowTicks = _clock.GetTimestamp(); + if (_lastSampleTimestamp.TryGetValue(streamId, out var lastTicks)) + { + var elapsed = _clock.GetElapsedTime(lastTicks, nowTicks); + var delivered = _deliveredSinceSample.GetValueOrDefault(streamId, 0); + var newWindow = _scaler.ComputeNewWindow(_initialRecvStreamWindow, delivered, elapsed, MinRtt); + if (newWindow > _initialRecvStreamWindow) + { + increment += newWindow - _initialRecvStreamWindow; + _initialRecvStreamWindow = newWindow; + _windowUpdateThreshold = Math.Max(8 * 1024, newWindow / 4); + } + } + + _lastSampleTimestamp[streamId] = nowTicks; + _deliveredSinceSample[streamId] = 0; + } + _recvStreamWindows[streamId] += increment; streamUpdate = new WindowUpdateSignal(streamId, increment); _pendingStreamIncrements[streamId] = 0; @@ -150,6 +220,8 @@ public void ApplyInitialWindowSizeDelta(long delta) _pendingStreamIncrements.Remove(streamId); _recvStreamWindows.Remove(streamId); _streamSendWindows.Remove(streamId); + _deliveredSinceSample.Remove(streamId); + _lastSampleTimestamp.Remove(streamId); return signal; } @@ -162,7 +234,7 @@ public void OnGoAway() public void Reset(int connectionWindowSize, int streamWindowSize) { GoAwayReceived = false; - _recvConnectionWindow = connectionWindowSize; + RecvConnectionWindow = connectionWindowSize; _initialRecvStreamWindow = streamWindowSize; _connectionSendWindow = 65535; _initialSendStreamWindow = 65535; @@ -170,9 +242,12 @@ public void Reset(int connectionWindowSize, int streamWindowSize) _streamSendWindows.Clear(); _pendingConnIncrement = 0; _pendingStreamIncrements.Clear(); + _deliveredSinceSample.Clear(); + _lastSampleTimestamp.Clear(); + _rtt?.Reset(); - const int minWindowUpdateThreshold = 8_192; - _windowUpdateThreshold = Math.Max(minWindowUpdateThreshold, streamWindowSize / 2); + const int minWindowUpdateThreshold = 8 * 1024; + _windowUpdateThreshold = Math.Max(minWindowUpdateThreshold, streamWindowSize / 4); } public SettingsResult OnRemoteSettings(SettingsFrame frame) diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs index 36930f892..f8e60bea3 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs @@ -22,7 +22,7 @@ internal sealed class FrameDecoder : IDisposable // RFC 9113 §6.5.2: SETTINGS_MAX_FRAME_SIZE must be in [2^14, 2^24−1]. private const uint MinMaxFrameSize = 16 * 1024; - private const uint MaxMaxFrameSize = (16 * 1024 * 1024) - 1; + private const uint MaxMaxFrameSize = 16 * 1024 * 1024 - 1; // RFC 9113 §6.7: PING payload is exactly 8 bytes. private const int PingPayloadSize = 8; @@ -40,6 +40,17 @@ internal sealed class FrameDecoder : IDisposable // RFC 9113 §6.1 / §6.2: one-byte Pad Length field precedes padded data. private const int PadLengthFieldSize = 1; + // RFC 9113 §4.2: the largest inbound frame payload we accept — the SETTINGS_MAX_FRAME_SIZE we + // advertise to the peer. Frames larger than this are a FRAME_SIZE_ERROR and are rejected before + // their payload is buffered, bounding per-connection memory. Defaults to the 24-bit ceiling so a + // decoder constructed without an explicit limit performs no enforcement beyond the wire maximum. + private readonly int _maxFrameSize; + + public FrameDecoder(int maxFrameSize = (int)MaxMaxFrameSize) + { + _maxFrameSize = maxFrameSize; + } + // Owned working buffer. Kept alive between Decode() calls so that returned frame slices // remain valid until the next call (Akka back-pressure guarantees frames are consumed first). private TransportBuffer? _workingBuffer; @@ -119,6 +130,14 @@ public IReadOnlyList Decode(TransportBuffer buffer) var span = working.Span[offset..]; var payloadLen = (span[0] << 16) | (span[1] << 8) | span[2]; + // RFC 9113 §4.2: reject oversized frames before buffering their payload, so a peer cannot + // force us to accumulate an arbitrarily large frame. + if (payloadLen > _maxFrameSize) + { + throw new HttpProtocolException( + $"RFC 9113 §4.2: frame payload length {payloadLen} exceeds advertised SETTINGS_MAX_FRAME_SIZE {_maxFrameSize}."); + } + if (workingLength - offset < FrameHeaderSize + payloadLen) { break; @@ -185,7 +204,7 @@ public void Dispose() : new ContinuationFrame( streamId, payload, - (flags & (byte)ContinuationFlags.EndHeaders) != 0), + (flags & (byte)Continuations.EndHeaders) != 0), FrameType.Ping => streamId != 0 ? throw new HttpProtocolException("RFC 9113 §6.7: PING frame MUST be sent on stream 0.") @@ -222,10 +241,10 @@ private static DataFrame ParseDataFrame(byte flags, int streamId, ReadOnlyMemory "RFC 9113 §6.1: DATA frame MUST be associated with a stream; stream 0 is invalid."); } - var endStream = (flags & (byte)DataFlags.EndStream) != 0; + var endStream = (flags & (byte)Datas.EndStream) != 0; var data = payload; - if ((flags & (byte)DataFlags.Padded) != 0) + if ((flags & (byte)Datas.Padded) != 0) { if (data.IsEmpty) { @@ -282,7 +301,7 @@ private static PingFrame CreatePing(byte flags, ReadOnlyMemory payload) $"PING frame must be exactly {PingPayloadSize} bytes, got {payload.Length}"); } - return new PingFrame(payload, (flags & (byte)PingFlags.Ack) != 0); + return new PingFrame(payload, (flags & (byte)Pings.Ack) != 0); } private static SettingsFrame ParseSettings(ReadOnlyMemory payload, byte flags) diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs index ad713f150..e57f62456 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs @@ -24,7 +24,7 @@ internal sealed class HpackDecoder // RFC 7541 §4.2: Maximum table size is negotiated via SETTINGS_HEADER_TABLE_SIZE private int _maxAllowedTableSize = 4096; - // RFC 9113 §6.5.2 / RFC 7541: MAX_HEADER_LIST_SIZE — maximum cumulative decoded header list size. + // RFC 9113 §6.5.2 / RFC 7541: MAX_HEADER_LIST_SIZE - maximum cumulative decoded header list size. // Size is computed as: sum of (name_bytes + value_bytes + 32) per entry. // Default: int.MaxValue (no limit enforced until SETTINGS is received). private int _maxHeaderListSize = int.MaxValue; @@ -56,7 +56,7 @@ public void SetMaxAllowedTableSize(int size) /// /// Sets the MAX_HEADER_LIST_SIZE limit (RFC 9113 §6.5.2). /// When the cumulative decoded header list size (name + value + 32 per entry) exceeds - /// this value, is thrown (COMPRESSION_ERROR — connection error). + /// this value, is thrown (COMPRESSION_ERROR - connection error). /// public void SetMaxHeaderListSize(int size) { @@ -110,7 +110,7 @@ public List Decode(ReadOnlySpan data) { tableSizeUpdateAllowed = false; var idx = ReadInteger(data, ref pos, 7); - // Use LookupWithSizes to retrieve the cached encoded size — + // Use LookupWithSizes to retrieve the cached encoded size - // zero GetByteCount calls for both static (pre-computed) and dynamic (cached) entries. var (header, _, encodedSize) = LookupWithSizes(idx); CheckHeaderListSizeFromEncoded(ref cumulativeHeaderListSize, encodedSize); @@ -189,7 +189,7 @@ private void CheckHeaderListSize(ref long cumulative, int nameByteLength, int va { throw new HpackException( $"RFC 9113 §6.5.2 violation: Header list size {cumulative} exceeds " + - $"MAX_HEADER_LIST_SIZE ({_maxHeaderListSize}) — COMPRESSION_ERROR."); + $"MAX_HEADER_LIST_SIZE ({_maxHeaderListSize}) - COMPRESSION_ERROR."); } } @@ -211,7 +211,7 @@ private void CheckHeaderListSizeFromEncoded(ref long cumulative, int encodedSize { throw new HpackException( $"RFC 9113 §6.5.2 violation: Header list size {cumulative} exceeds " + - $"MAX_HEADER_LIST_SIZE ({_maxHeaderListSize}) — COMPRESSION_ERROR."); + $"MAX_HEADER_LIST_SIZE ({_maxHeaderListSize}) - COMPRESSION_ERROR."); } } @@ -238,7 +238,7 @@ private void CheckHeaderListSizeFromEncoded(ref long cumulative, int encodedSize else { // Name is referenced from the static or dynamic table. - // Use LookupWithSizes to retrieve the cached name byte length — + // Use LookupWithSizes to retrieve the cached name byte length - // zero GetByteCount calls for both static (pre-computed) and dynamic (cached) entries. var (looked, cachedNameByteLength, _) = LookupWithSizes(idx); name = looked.Name; @@ -303,7 +303,7 @@ internal static int ReadInteger(ReadOnlySpan data, ref int pos, int prefix return value; } - // Multi-byte integer decoding — use long to detect overflow before truncating to int + // Multi-byte integer decoding - use long to detect overflow before truncating to int var shift = 0; long lvalue = value; while (true) @@ -363,7 +363,7 @@ internal static int ReadInteger(ReadOnlySpan data, ref int pos, int prefix { throw new HpackException( $"RFC 7541 §5.2 violation: String literal length {length} exceeds maximum {_maxStringLength} " + - $"— COMPRESSION_ERROR."); + $"- COMPRESSION_ERROR."); } if (pos + length > data.Length) diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs index ed770acd3..d9e68f6f5 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs @@ -114,7 +114,10 @@ public ReadOnlyMemory Encode(IReadOnlyList<(string Name, string Value)> he { ArgumentNullException.ThrowIfNull(headers); - var owner = MemoryPool.Shared.Rent(4096); + // Size the buffer to the worst-case encoded length so large cookies/JWTs don't overflow the span + // (the previous fixed 4096-byte rent threw IndexOutOfRange mid-encode). Huffman never exceeds the + // raw byte count, so the non-Huffman upper bound below is always sufficient. + var owner = MemoryPool.Shared.Rent(Math.Max(4096, EstimateMaxEncodedSize(headers))); var span = owner.Memory.Span; var totalWritten = 0; @@ -139,6 +142,26 @@ public ReadOnlyMemory Encode(IReadOnlyList<(string Name, string Value)> he return (owner, totalWritten); } + /// + /// Worst-case encoded size for the header list (RFC 7541): per header, a representation integer + /// (≤5 bytes), a name-length prefix (≤5 bytes) and a value-length prefix (≤5 bytes), plus the raw + /// UTF-8 name and value bytes. A leading dynamic-table-size update adds at most a few bytes. + /// Huffman encoding only shrinks strings, so this bound holds for both Huffman and raw output. + /// + private static int EstimateMaxEncodedSize(IReadOnlyList<(string Name, string Value)> headers) + { + var total = 8; // leading dynamic table size update + for (var i = 0; i < headers.Count; i++) + { + var (name, value) = headers[i]; + total += 16 + + Encoding.UTF8.GetByteCount(name ?? string.Empty) + + Encoding.UTF8.GetByteCount(value ?? string.Empty); + } + + return total; + } + private int EncodeHeader(HpackHeader header, ref Span output, bool useHuffman) { // Automatically upgrade sensitive headers to NeverIndexed (RFC 7541 §7.1) @@ -313,7 +336,7 @@ private static int WriteString(string value, ref Span output, bool useHuff var utf8Start = output.Length - rawLength; if (utf8Start < maxHuffLen + 6) { - // Span is tight — fall through to non-Huffman path if Huffman can't possibly help + // Span is tight - fall through to non-Huffman path if Huffman can't possibly help // (This is a safety check; in practice, the caller provides ample space) } else @@ -326,7 +349,7 @@ private static int WriteString(string value, ref Span output, bool useHuff if (huffLen < rawLength) { - // Huffman wins — write length prefix with H bit, then Huffman data + // Huffman wins - write length prefix with H bit, then Huffman data var written = WriteInteger(huffLen, prefixBits: 7, prefixFlags: 0x80, ref output); var actualHuffLen = HuffmanCodec.Encode(utf8Region[..rawLength], output[..huffLen]); output = output[actualHuffLen..]; diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoding.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoding.cs index c435c7f2c..035e702ea 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoding.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoding.cs @@ -25,5 +25,5 @@ internal enum HpackEncoding /// The header MUST NOT be indexed by any intermediary. /// Mandatory for security-sensitive fields: Authorization, Cookie, Set-Cookie. /// - NeverIndexed, + NeverIndexed } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackStaticTable.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackStaticTable.cs index 758f7a358..e46635b97 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackStaticTable.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackStaticTable.cs @@ -40,7 +40,7 @@ static HpackStaticTable() for (var i = 1; i <= StaticCount; i++) { - dict.TryAdd(Entries[i].Name, i); // first occurrence wins — entries are 1-based + dict.TryAdd(Entries[i].Name, i); // first occurrence wins - entries are 1-based // Precompute name and entry sizes so the decoder never calls GetByteCount on static entries. var nameBytes = Encoding.UTF8.GetByteCount(Entries[i].Name); @@ -55,20 +55,20 @@ static HpackStaticTable() public static readonly (string Name, string Value)[] Entries = [ (string.Empty, string.Empty), // [0] reserved - (":authority", string.Empty), // [1] - (":method", "GET"), // [2] - (":method", "POST"), // [3] - (":path", "/"), // [4] - (":path", "/index.html"), // [5] - (":scheme", "http"), // [6] - (":scheme", "https"), // [7] - (":status", "200"), // [8] - (":status", "204"), // [9] - (":status", "206"), // [10] - (":status", "304"), // [11] - (":status", "400"), // [12] - (":status", "404"), // [13] - (":status", "500"), // [14] + (WellKnownHeaders.Authority, string.Empty), // [1] + (WellKnownHeaders.Method, "GET"), // [2] + (WellKnownHeaders.Method, "POST"), // [3] + (WellKnownHeaders.Path, "/"), // [4] + (WellKnownHeaders.Path, "/index.html"), // [5] + (WellKnownHeaders.Scheme, "http"), // [6] + (WellKnownHeaders.Scheme, "https"), // [7] + (WellKnownHeaders.Status, "200"), // [8] + (WellKnownHeaders.Status, "204"), // [9] + (WellKnownHeaders.Status, "206"), // [10] + (WellKnownHeaders.Status, "304"), // [11] + (WellKnownHeaders.Status, "400"), // [12] + (WellKnownHeaders.Status, "404"), // [13] + (WellKnownHeaders.Status, "500"), // [14] ("accept-charset", string.Empty), // [15] ("accept-encoding", "gzip, deflate"), // [16] ("accept-language", string.Empty), // [17] diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Http2Frame.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Http2Frame.cs index 966908a2e..41f5b3ee4 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Http2Frame.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Http2Frame.cs @@ -1,6 +1,6 @@ namespace TurboHTTP.Protocol.Syntax.Http2; -// HTTP/2 Frame Types — RFC 9113 §6 +// HTTP/2 Frame Types - RFC 9113 §6 // // Frame-Header (9 Bytes, RFC 9113 §4.1): // +-----------------------------------------------+ @@ -23,15 +23,15 @@ internal enum FrameType : byte Ping = 0x6, GoAway = 0x7, WindowUpdate = 0x8, - Continuation = 0x9, + Continuation = 0x9 } [Flags] -internal enum DataFlags : byte +internal enum Datas : byte { None = 0x0, EndStream = 0x1, - Padded = 0x8, + Padded = 0x8 } [Flags] @@ -41,28 +41,28 @@ internal enum Headers : byte EndStream = 0x1, EndHeaders = 0x4, Padded = 0x8, - Priority = 0x20, + Priority = 0x20 } [Flags] internal enum Settings : byte { None = 0x0, - Ack = 0x1, + Ack = 0x1 } [Flags] -internal enum PingFlags : byte +internal enum Pings : byte { None = 0x0, - Ack = 0x1, + Ack = 0x1 } [Flags] -internal enum ContinuationFlags : byte +internal enum Continuations : byte { None = 0x0, - EndHeaders = 0x4, + EndHeaders = 0x4 } internal enum SettingsParameter : ushort @@ -72,7 +72,7 @@ internal enum SettingsParameter : ushort MaxConcurrentStreams = 0x3, InitialWindowSize = 0x4, MaxFrameSize = 0x5, - MaxHeaderListSize = 0x6, + MaxHeaderListSize = 0x6 } internal enum Http2ErrorCode : uint @@ -90,7 +90,7 @@ internal enum Http2ErrorCode : uint ConnectError = 0xa, EnhanceYourCalm = 0xb, InadequateSecurity = 0xc, - Http11Required = 0xd, + Http11Required = 0xd } internal abstract class Http2Frame(int streamId) @@ -122,46 +122,51 @@ protected static void WriteHeader(ref SpanWriter w, int payloadLength, FrameType } protected const int FrameHeaderSize = 9; + + internal const int HeaderSize = 9; } -internal sealed class DataFrame : Http2Frame +internal sealed class DataFrame(int streamId, ReadOnlyMemory data, bool endStream = false) + : Http2Frame(streamId) { public override FrameType Type => FrameType.Data; - public ReadOnlyMemory Data { get; } - public bool EndStream { get; } - - public DataFrame(int streamId, ReadOnlyMemory data, bool endStream = false) : base(streamId) - { - Data = data; - EndStream = endStream; - } + public ReadOnlyMemory Data { get; } = data; + public bool EndStream { get; } = endStream; public override int SerializedSize => FrameHeaderSize + Data.Length; public override void WriteTo(ref Span span) { var w = SpanWriter.Create(span); - var flags = EndStream ? (byte)DataFlags.EndStream : (byte)DataFlags.None; + var flags = EndStream ? (byte)Datas.EndStream : (byte)Datas.None; WriteHeader(ref w, Data.Length, FrameType.Data, flags, StreamId); w.WriteBytes(Data.Span); span = span[w.BytesWritten..]; } + + // Writes the 9-byte DATA frame header in-place at dest[offset..offset+9]. + // The caller is responsible for ensuring dest is large enough and that the + // payload already sits at dest[offset+9..offset+9+payloadLength]. + public static void WriteHeaderInPlace(Span dest, int offset, int streamId, int payloadLength, bool endStream) + { + var slice = dest.Slice(offset, FrameHeaderSize); + var w = SpanWriter.Create(slice); + var flags = endStream ? (byte)Datas.EndStream : (byte)Datas.None; + WriteHeader(ref w, payloadLength, FrameType.Data, flags, streamId); + } } -internal sealed class HeadersFrame : Http2Frame +internal sealed class HeadersFrame( + int streamId, + ReadOnlyMemory headerBlock, + bool endStream = false, + bool endHeaders = true) + : Http2Frame(streamId) { public override FrameType Type => FrameType.Headers; - public ReadOnlyMemory HeaderBlockFragment { get; } - public bool EndStream { get; } - public bool EndHeaders { get; } - - public HeadersFrame(int streamId, ReadOnlyMemory headerBlock, bool endStream = false, bool endHeaders = true) - : base(streamId) - { - HeaderBlockFragment = headerBlock; - EndStream = endStream; - EndHeaders = endHeaders; - } + public ReadOnlyMemory HeaderBlockFragment { get; } = headerBlock; + public bool EndStream { get; } = endStream; + public bool EndHeaders { get; } = endHeaders; public override int SerializedSize => FrameHeaderSize + HeaderBlockFragment.Length; @@ -185,37 +190,29 @@ public override void WriteTo(ref Span span) } } -internal sealed class ContinuationFrame : Http2Frame +internal sealed class ContinuationFrame(int streamId, ReadOnlyMemory headerBlock, bool endHeaders = true) + : Http2Frame(streamId) { public override FrameType Type => FrameType.Continuation; - public ReadOnlyMemory HeaderBlockFragment { get; } - public bool EndHeaders { get; } - - public ContinuationFrame(int streamId, ReadOnlyMemory headerBlock, bool endHeaders = true) : base(streamId) - { - HeaderBlockFragment = headerBlock; - EndHeaders = endHeaders; - } + public ReadOnlyMemory HeaderBlockFragment { get; } = headerBlock; + public bool EndHeaders { get; } = endHeaders; public override int SerializedSize => FrameHeaderSize + HeaderBlockFragment.Length; public override void WriteTo(ref Span span) { var w = SpanWriter.Create(span); - var flags = EndHeaders ? (byte)ContinuationFlags.EndHeaders : (byte)0; + var flags = EndHeaders ? (byte)Continuations.EndHeaders : (byte)0; WriteHeader(ref w, HeaderBlockFragment.Length, FrameType.Continuation, flags, StreamId); w.WriteBytes(HeaderBlockFragment.Span); span = span[w.BytesWritten..]; } } -internal sealed class RstStreamFrame : Http2Frame +internal sealed class RstStreamFrame(int streamId, Http2ErrorCode errorCode) : Http2Frame(streamId) { public override FrameType Type => FrameType.RstStream; - public Http2ErrorCode ErrorCode { get; } - - public RstStreamFrame(int streamId, Http2ErrorCode errorCode) : base(streamId) - => ErrorCode = errorCode; + public Http2ErrorCode ErrorCode { get; } = errorCode; public override int SerializedSize => FrameHeaderSize + 4; @@ -228,17 +225,12 @@ public override void WriteTo(ref Span span) } } -internal sealed class SettingsFrame : Http2Frame +internal sealed class SettingsFrame(IReadOnlyList<(SettingsParameter Key, uint Value)> parameters, bool isAck = false) + : Http2Frame(0) { public override FrameType Type => FrameType.Settings; - public IReadOnlyList<(SettingsParameter, uint)> Parameters { get; } - public bool IsAck { get; } - - public SettingsFrame(IReadOnlyList<(SettingsParameter Key, uint Value)> parameters, bool isAck = false) : base(0) - { - Parameters = parameters; - IsAck = isAck; - } + public IReadOnlyList<(SettingsParameter, uint)> Parameters { get; } = parameters; + public bool IsAck { get; } = isAck; public override int SerializedSize => FrameHeaderSize + (IsAck ? 0 : Parameters.Count * 6); @@ -290,7 +282,7 @@ public PingFrame(ReadOnlyMemory data, bool isAck = false) : base(0) public override void WriteTo(ref Span span) { var w = SpanWriter.Create(span); - var flags = IsAck ? (byte)PingFlags.Ack : (byte)0; + var flags = IsAck ? (byte)Pings.Ack : (byte)0; WriteHeader(ref w, 8, FrameType.Ping, flags, 0); w.WriteBytes(Data.Span); span = span[w.BytesWritten..]; @@ -356,21 +348,17 @@ public override void WriteTo(ref Span span) } } -internal sealed class PushPromiseFrame : Http2Frame +internal sealed class PushPromiseFrame( + int streamId, + int promisedStreamId, + ReadOnlyMemory headerBlock, + bool endHeaders = true) + : Http2Frame(streamId) { public override FrameType Type => FrameType.PushPromise; - public int PromisedStreamId { get; } - private ReadOnlyMemory HeaderBlockFragment { get; } - public bool EndHeaders { get; } - - public PushPromiseFrame(int streamId, int promisedStreamId, ReadOnlyMemory headerBlock, - bool endHeaders = true) - : base(streamId) - { - PromisedStreamId = promisedStreamId; - HeaderBlockFragment = headerBlock; - EndHeaders = endHeaders; - } + public int PromisedStreamId { get; } = promisedStreamId; + private ReadOnlyMemory HeaderBlockFragment { get; } = headerBlock; + public bool EndHeaders { get; } = endHeaders; public override int SerializedSize => FrameHeaderSize + 4 + HeaderBlockFragment.Length; diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptions.cs index b354373a7..95ea20b82 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientDecoderOptions.cs @@ -2,35 +2,12 @@ namespace TurboHTTP.Protocol.Syntax.Http2.Options; internal sealed record Http2ClientDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int MaxConcurrentStreams { get; init; } = 100; - public int InitialConnectionWindowSize { get; init; } = 64 * 1024 * 1024; - public int InitialStreamWindowSize { get; init; } = 2 * 1024 * 1024; - - public static Http2ClientDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (MaxConcurrentStreams <= 0) - { - throw new ArgumentException("MaxConcurrentStreams must be > 0.", nameof(MaxConcurrentStreams)); - } - - if (InitialConnectionWindowSize <= 0) - { - throw new ArgumentException("InitialConnectionWindowSize must be > 0.", nameof(InitialConnectionWindowSize)); - } - - if (InitialStreamWindowSize <= 0) - { - throw new ArgumentException("InitialStreamWindowSize must be > 0.", nameof(InitialStreamWindowSize)); - } - - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required int MaxConcurrentStreams { get; init; } + public required int InitialConnectionWindowSize { get; init; } + public required int InitialStreamWindowSize { get; init; } + public required int MaxStreamWindowSize { get; init; } + public required double WindowScaleThresholdMultiplier { get; init; } + public required bool EnableAdaptiveWindowScaling { get; init; } + public required int MaxHeaderSize { get; init; } + public required int MaxHeaderListSize { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptions.cs index 663761511..a7f417339 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ClientEncoderOptions.cs @@ -2,29 +2,6 @@ namespace TurboHTTP.Protocol.Syntax.Http2.Options; internal sealed record Http2ClientEncoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int HeaderTableSize { get; init; } = 64 * 1024; - public int MaxFrameSize { get; init; } = 16 * 1024; - - public static Http2ClientEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (HeaderTableSize < 0) - { - throw new ArgumentException("HeaderTableSize must be >= 0.", nameof(HeaderTableSize)); - } - - if (MaxFrameSize is < 16 * 1024 or > (16 * 1024 * 1024) - 1) - { - throw new ArgumentException("MaxFrameSize must be between 16384 and 16777215.", nameof(MaxFrameSize)); - } - - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required int HeaderTableSize { get; init; } + public required int MaxFrameSize { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerDecoderOptions.cs index 5837f5f5e..d7f61729d 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerDecoderOptions.cs @@ -2,29 +2,14 @@ namespace TurboHTTP.Protocol.Syntax.Http2.Options; internal sealed record Http2ServerDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int MaxConcurrentStreams { get; init; } = 100; - public int MaxFieldSectionSize { get; init; } = 64 * 1024; - - public static Http2ServerDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (MaxConcurrentStreams <= 0) - { - throw new ArgumentException("MaxConcurrentStreams must be > 0.", nameof(MaxConcurrentStreams)); - } - - if (MaxFieldSectionSize <= 0) - { - throw new ArgumentException("MaxFieldSectionSize must be > 0.", nameof(MaxFieldSectionSize)); - } - - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required int HeaderTableSize { get; init; } + public required int MaxConcurrentStreams { get; init; } + public required int MaxFieldSectionSize { get; init; } + public required int MaxHeaderBytes { get; init; } + public required int MaxHeaderCount { get; init; } + public int InitialConnectionWindowSize { get; init; } = 1 * 1024 * 1024; + public int InitialStreamWindowSize { get; init; } = 768 * 1024; + public int MaxStreamWindowSize { get; init; } = 8 * 1024 * 1024; + public double WindowScaleThresholdMultiplier { get; init; } = 1.0; + public bool EnableAdaptiveWindowScaling { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerEncoderOptions.cs index eebabd41f..c7275d461 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerEncoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Options/Http2ServerEncoderOptions.cs @@ -2,30 +2,9 @@ namespace TurboHTTP.Protocol.Syntax.Http2.Options; internal sealed record Http2ServerEncoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public bool WriteDateHeader { get; init; } = true; - public int HeaderTableSize { get; init; } = 64 * 1024; - public int MaxFrameSize { get; init; } = 16 * 1024; - - public static Http2ServerEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (HeaderTableSize < 0) - { - throw new ArgumentException("HeaderTableSize must be >= 0.", nameof(HeaderTableSize)); - } - - if (MaxFrameSize is < 16 * 1024 or > (16 * 1024 * 1024) - 1) - { - throw new ArgumentException("MaxFrameSize must be between 16384 and 16777215.", nameof(MaxFrameSize)); - } - - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - } + public required int MaxFrameSize { get; init; } + public required int HeaderTableSize { get; init; } + public required bool WriteDateHeader { get; init; } + public required int MaxHeaderBytes { get; init; } + public required bool UseHuffman { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/PrefaceBuilder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/PrefaceBuilder.cs index 582e54012..1c34cedb3 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/PrefaceBuilder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/PrefaceBuilder.cs @@ -4,10 +4,26 @@ namespace TurboHTTP.Protocol.Syntax.Http2; internal static class PrefaceBuilder { + private const int DefaultInitialWindowSize = 65535; + + /// + /// Builds the HTTP/2 client connection preface (magic + SETTINGS [+ optional connection + /// WINDOW_UPDATE]). + /// + /// + /// Per-stream receive window advertised via SETTINGS_INITIAL_WINDOW_SIZE (RFC 9113 §6.5.2). + /// This must match the credit the local FlowController grants each stream - advertising the + /// connection window here lets the peer overrun a stream and trips a false FLOW_CONTROL_ERROR. + /// + /// + /// Desired connection-level receive window. SETTINGS cannot change the connection window, so any + /// amount above the protocol default (65535) is granted via a stream-0 WINDOW_UPDATE (RFC 9113 §6.9). + /// public static (IMemoryOwner Owner, int Length) Build( - int initialWindowSize, - int headerTableSize = 4096, - int maxFrameSize = 16 * 1024) + int streamInitialWindowSize, + int connectionInitialWindowSize, + int headerTableSize, + int maxFrameSize) { const int frameHeaderSize = 9; var magic = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8; @@ -16,12 +32,12 @@ public static (IMemoryOwner Owner, int Length) Build( { (SettingsParameter.HeaderTableSize, (uint)headerTableSize), (SettingsParameter.EnablePush, 0), - (SettingsParameter.InitialWindowSize, (uint)initialWindowSize), - (SettingsParameter.MaxFrameSize, (uint)maxFrameSize), + (SettingsParameter.InitialWindowSize, (uint)streamInitialWindowSize), + (SettingsParameter.MaxFrameSize, (uint)maxFrameSize) }; var settingsPayloadSize = settingsParams.Length * 6; - var needsWindowUpdate = initialWindowSize > 65535; + var needsWindowUpdate = connectionInitialWindowSize > DefaultInitialWindowSize; const int windowUpdatePayloadSize = 4; var totalSize = magic.Length + frameHeaderSize + settingsPayloadSize; if (needsWindowUpdate) @@ -47,7 +63,7 @@ public static (IMemoryOwner Owner, int Length) Build( if (!needsWindowUpdate) return (owner, totalSize); - var windowUpdateIncrement = initialWindowSize - 65535; + var windowUpdateIncrement = connectionInitialWindowSize - DefaultInitialWindowSize; w.WriteUInt24BigEndian(windowUpdatePayloadSize); w.WriteByte((byte)FrameType.WindowUpdate); w.WriteByte(0); diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/RttEstimator.cs b/src/TurboHTTP/Protocol/Syntax/Http2/RttEstimator.cs new file mode 100644 index 000000000..f35a9d8cb --- /dev/null +++ b/src/TurboHTTP/Protocol/Syntax/Http2/RttEstimator.cs @@ -0,0 +1,65 @@ +namespace TurboHTTP.Protocol.Syntax.Http2; + +/// +/// Measures the connection's base round-trip time via correlated PINGs and decides when the next +/// measurement PING is due. Actor-confined: no synchronization. Clocked via +/// so tests can drive it deterministically with FakeTimeProvider. +/// +internal sealed class RttEstimator(TimeProvider clock, TimeSpan pingInterval) +{ + private long _pingSentTimestamp; + private long _lastPingTimestamp; + private bool _awaitingAck; + private bool _everPinged; + + /// Smallest RTT observed so far. means "unknown / no sample yet". + public TimeSpan MinRtt { get; private set; } = TimeSpan.Zero; + + /// True when no measurement is in flight and the interval since the last ping has elapsed. + public bool ShouldSendPing() + { + if (_awaitingAck) + { + return false; + } + + if (!_everPinged) + { + return true; + } + + return clock.GetElapsedTime(_lastPingTimestamp) >= pingInterval; + } + + public void OnPingSent() + { + _pingSentTimestamp = clock.GetTimestamp(); + _lastPingTimestamp = _pingSentTimestamp; + _awaitingAck = true; + _everPinged = true; + } + + public void OnPingAck() + { + if (!_awaitingAck) + { + return; + } + + _awaitingAck = false; + var rtt = clock.GetElapsedTime(_pingSentTimestamp); + if (MinRtt == TimeSpan.Zero || rtt < MinRtt) + { + MinRtt = rtt; + } + } + + public void Reset() + { + MinRtt = TimeSpan.Zero; + _awaitingAck = false; + _everPinged = false; + _pingSentTimestamp = 0; + _lastPingTimestamp = 0; + } +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/BodyRateState.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/BodyRateState.cs deleted file mode 100644 index e68e595fe..000000000 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/BodyRateState.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace TurboHTTP.Protocol.Syntax.Http2.Server; - -/// -/// Tracks request body data rate for a single stream. -/// Used to enforce minimum data rate with grace period, compatible with Kestrel's timeout model. -/// -internal sealed class BodyRateState -{ - /// - /// Total bytes received on this stream. - /// - public long TotalBytes { get; set; } - - /// - /// Bytes recorded at last check time (used to calculate rate). - /// - public long LastCheckBytes { get; set; } - - /// - /// Timestamp (in milliseconds from Environment.TickCount64) of last rate check. - /// - public long LastCheckTimestamp { get; set; } = Environment.TickCount64; - - /// - /// Timestamp (in milliseconds from Environment.TickCount64) when grace period started. - /// - public long GracePeriodStartTimestamp { get; set; } = Environment.TickCount64; - - /// - /// Whether the stream is currently in its grace period (allowed to have slow data rate). - /// - public bool InGracePeriod { get; set; } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerDecoder.cs index 4fdbcf354..ab796fdfc 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerDecoder.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Http; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Server.Context.Features; namespace TurboHTTP.Protocol.Syntax.Http2.Server; @@ -10,22 +10,28 @@ internal sealed class Http2ServerDecoder private const string PseudoHeaderSection = "RFC 9113 §8.3.1"; private const string UppercaseSection = "RFC 9113 §8.2.1"; private const string TokenSection = "RFC 9113 §10.3"; - private const string FieldValueSection = "RFC 9113 §10.3"; private const string ConnectionSection = "RFC 9113 §8.2.2"; private HpackDecoder _hpack = new(); private readonly int _maxHeaderSize; private readonly int _maxTotalHeaderSize; + private readonly int _maxHeaderCount; - public Http2ServerDecoder(int maxHeaderSize = 16 * 1024, int maxTotalHeaderSize = 64 * 1024) + public Http2ServerDecoder(Http2ServerDecoderOptions options) { - _maxHeaderSize = maxHeaderSize; - _maxTotalHeaderSize = maxTotalHeaderSize; + ArgumentNullException.ThrowIfNull(options); + _maxHeaderSize = options.MaxHeaderBytes; + _maxTotalHeaderSize = options.MaxFieldSectionSize; + _maxHeaderCount = options.MaxHeaderCount; + // RFC 9113 §6.5.2: enforce the cumulative decoded header-list size (MAX_HEADER_LIST_SIZE) inside + // the HPACK decoder so a decompression bomb is rejected mid-decode, before the full list is built. + _hpack.SetMaxHeaderListSize(_maxTotalHeaderSize); } public void ResetHpack() { _hpack = new HpackDecoder(); + _hpack.SetMaxHeaderListSize(_maxTotalHeaderSize); } public TurboHttpRequestFeature? DecodeHeadersToFeature(int streamId, bool endStream, StreamState state) @@ -34,12 +40,13 @@ public void ResetHpack() ValidateHeaderSize(headers, streamId); ValidateRequestHeaders(headers); - var feature = new TurboHttpRequestFeature { Protocol = "HTTP/2" }; - var headerDict = new HeaderDictionary(); + var feature = new TurboHttpRequestFeature { Protocol = WellKnownHeaders.Http20 }; + // Write directly into the feature's header dictionary, avoiding a throwaway + // HeaderDictionary allocation plus the copy loop in the Headers setter. + var headerDict = feature.Headers; string? path = null; string? scheme = null; - string? authority = null; var isConnect = false; foreach (var h in headers) @@ -64,7 +71,6 @@ public void ResetHpack() } else if (h.Name == WellKnownHeaders.Authority) { - authority = h.Value; state.AddPseudoHeader(WellKnownHeaders.Authority, h.Value); } else if (!h.Name.StartsWith(WellKnownHeaders.Colon)) @@ -95,7 +101,6 @@ public void ResetHpack() feature.QueryString = queryIdx >= 0 ? path[queryIdx..] : string.Empty; } - feature.Headers = headerDict; state.InitRequestFeature(feature); if (!endStream) @@ -106,6 +111,28 @@ public void ResetHpack() return feature; } + public List<(string Name, string Value)> DecodeTrailers(StreamState state) + { + var headers = _hpack.Decode(state.GetHeaderSpan()); + var trailers = new List<(string Name, string Value)>(); + + foreach (var h in headers) + { + if (h.Name.StartsWith(WellKnownHeaders.Colon)) + { + throw new HttpProtocolException( + "RFC 9113 §8.1: Pseudo-headers are not allowed in trailers."); + } + + if (TrailerFieldValidator.IsAllowedInTrailer(h.Name)) + { + trailers.Add((h.Name, h.Value)); + } + } + + return trailers; + } + private static void ValidateRequestHeaders(List headers) { PseudoHeaderValidator.ValidateRequestPseudoHeaders( @@ -120,14 +147,20 @@ private static void ValidateRequestHeaders(List headers) static h => h.Value, UppercaseSection, TokenSection, - FieldValueSection, + TokenSection, ConnectionSection); } private void ValidateHeaderSize(List headers, int streamId) { - var totalHeaderSize = 0; + if (headers.Count > _maxHeaderCount) + { + throw new HttpProtocolException( + $"RFC 9113 §10.5.1: Header count {headers.Count} exceeds limit ({_maxHeaderCount}) on stream {streamId}."); + } + // Cumulative header-list size is enforced inside the HPACK decoder (MAX_HEADER_LIST_SIZE); here we + // only bound the size of any single header field (RFC 9113 §10.5.1). for (var i = 0; i < headers.Count; i++) { var headerSize = headers[i].Name.Length + headers[i].Value.Length; @@ -137,17 +170,7 @@ private void ValidateHeaderSize(List headers, int streamId) throw new HttpProtocolException( $"RFC 9113 §10.5.1: Single header field size {headerSize} bytes " + $"exceeds MaxHeaderSize limit ({_maxHeaderSize} bytes) " + - $"on stream {streamId} — header '{headers[i].Name}'."); - } - - totalHeaderSize += headerSize; - - if (totalHeaderSize > _maxTotalHeaderSize) - { - throw new HttpProtocolException( - $"RFC 9113 §10.5.1: Total header block size {totalHeaderSize} bytes " + - $"exceeds MaxTotalHeaderSize limit ({_maxTotalHeaderSize} bytes) " + - $"on stream {streamId}."); + $"on stream {streamId} - header '{headers[i].Name}'."); } } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs index e48894335..852c7c287 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.Features; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http2.Hpack; +using TurboHTTP.Protocol.Syntax.Http2.Options; namespace TurboHTTP.Protocol.Syntax.Http2.Server; @@ -13,7 +14,8 @@ namespace TurboHTTP.Protocol.Syntax.Http2.Server; /// internal sealed class Http2ServerEncoder { - private HpackEncoder _hpack = new(useHuffman: true); + private readonly Http2ServerEncoderOptions _options; + private HpackEncoder _hpack; // Reused across Encode() calls to avoid List allocation per response private readonly List _reusableHeaders = new(16); @@ -24,7 +26,15 @@ internal sealed class Http2ServerEncoder // Tracks MemoryPool rentals from the previous EncodeHeaders() call private readonly List> _rentedBodyOwners = new(4); - public int MaxFrameSize { get; private set; } = 16 * 1024; + public int MaxFrameSize { get; private set; } + + public Http2ServerEncoder(Http2ServerEncoderOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + _hpack = new HpackEncoder(useHuffman: options.UseHuffman); + MaxFrameSize = options.MaxFrameSize; + } private void EncodeHeaderFrames(List frames, int streamId, ReadOnlyMemory headerBlock, bool endStream) @@ -65,7 +75,7 @@ public IReadOnlyList EncodeHeaders(IFeatureCollection features, int var hpackOwner = MemoryPool.Shared.Rent(4096); _rentedBodyOwners.Add(hpackOwner); var hpackWritable = hpackOwner.Memory.Span; - var hpackBytesWritten = _hpack.Encode(_reusableHeaders, ref hpackWritable, useHuffman: true); + var hpackBytesWritten = _hpack.Encode(_reusableHeaders, ref hpackWritable, _options.UseHuffman); var headerBlock = hpackOwner.Memory[..hpackBytesWritten]; _reusableFrames.Clear(); @@ -74,7 +84,7 @@ public IReadOnlyList EncodeHeaders(IFeatureCollection features, int return _reusableFrames; } - private static void BuildHeaderList(IFeatureCollection features, List headers) + private void BuildHeaderList(IFeatureCollection features, List headers) { // RFC 9113 §7.2: :status pseudo-header (required) var responseFeature = features.Get(); @@ -93,10 +103,28 @@ private static void BuildHeaderList(IFeatureCollection features, List @@ -149,7 +177,7 @@ public IReadOnlyList EncodeTrailers(int streamId, IHeaderDictionary var hpackOwner = MemoryPool.Shared.Rent(4096); _rentedBodyOwners.Add(hpackOwner); var hpackWritable = hpackOwner.Memory.Span; - var hpackBytesWritten = _hpack.Encode(_reusableHeaders, ref hpackWritable, useHuffman: true); + var hpackBytesWritten = _hpack.Encode(_reusableHeaders, ref hpackWritable, _options.UseHuffman); var headerBlock = hpackOwner.Memory[..hpackBytesWritten]; _reusableFrames.Clear(); @@ -163,7 +191,7 @@ public IReadOnlyList EncodeTrailers(int streamId, IHeaderDictionary /// public void ResetHpack() { - _hpack = new HpackEncoder(useHuffman: true); + _hpack = new HpackEncoder(useHuffman: _options.UseHuffman); } private void ReturnRentedBuffers() diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs index ad8faf934..7976fd6c0 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs @@ -1,13 +1,17 @@ +using System.Buffers; using System.Text; +using Akka.Actor; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.Multiplexed; -using TurboHTTP.Protocol.Multiplexed.Body; +using TurboHTTP.Protocol.Semantics; +using TurboHTTP.Protocol.Syntax.Http2.Hpack; using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Streams.Stages.Server; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http2.Server; @@ -15,46 +19,96 @@ internal sealed class Http2ServerSessionManager { private const int MaxStatePoolCapacity = 1000; + // RFC 9113 §5.1 / CVE-2023-44487 (Rapid Reset): client-initiated resets are counted within this + // sliding window; exceeding the configured budget closes the connection with ENHANCE_YOUR_CALM. + private const long ResetWindowMs = 30_000; + + private const string DataRateCheck = "data-rate-check"; + + private readonly StackStreamStatePool _statePool; + private readonly Http2ServerEncoderOptions _encoderOptions; private readonly Http2ServerDecoderOptions _decoderOptions; private readonly IServerStageOperations _ops; - private readonly FrameDecoder _frameDecoder = new(); + private readonly FrameDecoder _frameDecoder; private readonly Http2ServerDecoder _requestDecoder; - private readonly Http2ServerEncoder _responseEncoder = new(); + private readonly Http2ServerEncoder _responseEncoder; private readonly FlowController _flow; private readonly StreamTracker _tracker; private readonly long _maxRequestBodySize; + private readonly BodyEncoderOptions _bodyEncoderOptions; + private readonly TimeSpan _bodyConsumptionTimeout; private readonly int _initialStreamWindowSize; private readonly Dictionary _streams = new(); - private readonly StackStreamStatePool _statePool; + + private readonly record struct StreamBodyReadComplete(int StreamId, int BytesRead); + + private readonly record struct StreamBodyReadFailed(int StreamId, Exception Reason); + + private readonly Dictionary _activeBodyStreams = new(); + private readonly Dictionary> _activeBodyBuffers = new(); private int _nextContinuationStreamId; private bool _continuationEndStream; - private readonly Dictionary _bodyRateStates = new(); + private readonly DataRateMonitor _requestRate; + private readonly DataRateMonitor _responseRate; + private readonly TimeProvider _clock; private bool _prefaceConsumed; + + private readonly int _maxResetStreamsPerWindow; + private int _resetCount; + private long _resetWindowStart; + + private bool _awaitingPingAck; + private long _pingSentTimestamp; + + private long Now() => _clock.GetUtcNow().ToUnixTimeMilliseconds(); + public int ActiveStreamCount => _streams.Count; public int MaxConcurrentStreams => _decoderOptions.MaxConcurrentStreams; public Http2ServerSessionManager( - Http2ServerEncoderOptions encoderOptions, - Http2ServerDecoderOptions decoderOptions, + Http2ConnectionOptions options, IServerStageOperations ops, - TurboServerOptions options) + TimeProvider? timeProvider = null) { - _encoderOptions = encoderOptions; - _decoderOptions = decoderOptions; + _clock = timeProvider ?? TimeProvider.System; + _encoderOptions = options.ToEncoderOptions(); + _decoderOptions = options.ToDecoderOptions(); _ops = ops ?? throw new ArgumentNullException(nameof(ops)); - - _requestDecoder = new Http2ServerDecoder(options.Http2.HeaderTableSize, options.Http2.MaxHeaderListSize); - _flow = new FlowController(options.Http2.InitialConnectionWindowSize, options.Http2.InitialStreamWindowSize); - _tracker = new StreamTracker(initialNextStreamId: 1, decoderOptions.MaxConcurrentStreams); - _maxRequestBodySize = options.Http2.MaxRequestBodySize; - _initialStreamWindowSize = options.Http2.InitialStreamWindowSize; + + _responseEncoder = new Http2ServerEncoder(_encoderOptions); + _requestDecoder = new Http2ServerDecoder(_decoderOptions); + // RFC 9113 §4.2: enforce the MAX_FRAME_SIZE we advertise in SETTINGS on inbound frames. + _frameDecoder = new FrameDecoder(_encoderOptions.MaxFrameSize); + WindowScaler? scaler = null; + if (_decoderOptions.EnableAdaptiveWindowScaling) + { + scaler = new WindowScaler( + _decoderOptions.MaxStreamWindowSize, + _decoderOptions.WindowScaleThresholdMultiplier); + } + + _flow = new FlowController( + options.InitialConnectionWindowSize, + options.InitialStreamWindowSize, + scaler, + _clock); + _tracker = new StreamTracker(initialNextStreamId: 1, options.MaxConcurrentStreams); + _maxRequestBodySize = options.Limits.MaxRequestBodySize; + _maxResetStreamsPerWindow = options.Limits.MaxResetStreamsPerWindow; + _bodyEncoderOptions = options.ToBodyEncoderOptions(); + _bodyConsumptionTimeout = options.BodyConsumptionTimeout; + _initialStreamWindowSize = options.InitialStreamWindowSize; + + var rate = options.ToRateMonitor(); + _requestRate = new DataRateMonitor(rate.MinRequestBodyDataRate, rate.MinRequestBodyDataRateGracePeriod); + _responseRate = new DataRateMonitor(rate.MinResponseDataRate, rate.MinResponseDataRateGracePeriod); var statePoolCapacity = Math.Min( - decoderOptions.MaxConcurrentStreams > 0 ? decoderOptions.MaxConcurrentStreams : 100, + options.MaxConcurrentStreams > 0 ? options.MaxConcurrentStreams : 100, MaxStatePoolCapacity); _statePool = new StackStreamStatePool( statePoolCapacity, @@ -68,7 +122,7 @@ public void PreStart() (SettingsParameter.MaxConcurrentStreams, (uint)_decoderOptions.MaxConcurrentStreams), (SettingsParameter.InitialWindowSize, (uint)_initialStreamWindowSize), (SettingsParameter.MaxFrameSize, (uint)_encoderOptions.MaxFrameSize), - (SettingsParameter.HeaderTableSize, (uint)_encoderOptions.HeaderTableSize), + (SettingsParameter.HeaderTableSize, (uint)_encoderOptions.HeaderTableSize) }; var settingsFrame = new SettingsFrame(settingsParams, isAck: false); @@ -81,20 +135,61 @@ public void PreStart() } } + /// + /// True once a connection-fatal protocol error (or graceful teardown) has occurred. The owning + /// state machine surfaces this so the stage flushes the pending GOAWAY and closes the connection. + /// + public bool ShouldComplete { get; internal set; } + public void DecodeClientData(TransportBuffer buffer) { - if (!_prefaceConsumed) + try { - SkipConnectionPreface(buffer); - } + if (!_prefaceConsumed) + { + SkipConnectionPreface(buffer); + } - var frames = _frameDecoder.Decode(buffer); - for (var i = 0; i < frames.Count; i++) + var frames = _frameDecoder.Decode(buffer); + for (var i = 0; i < frames.Count; i++) + { + ProcessFrame(frames[i]); + } + } + catch (StreamProtocolException e) + { + // RFC 9113 §5.4.2: stream-scoped error - reset just that stream, keep the connection. + EmitRstStream(e.StreamId, (Http2ErrorCode)e.ErrorCode); + } + catch (ConnectionProtocolException e) + { + TerminateConnection((Http2ErrorCode)e.ErrorCode, e.Message); + } + catch (HpackException e) + { + // RFC 9113 §4.3: HPACK decoding failures are a connection-level COMPRESSION_ERROR; the + // dynamic table is now desynchronized so the connection cannot continue. + TerminateConnection(Http2ErrorCode.CompressionError, e.Message); + } + catch (HuffmanException e) + { + TerminateConnection(Http2ErrorCode.CompressionError, e.Message); + } + catch (HttpProtocolException e) { - ProcessFrame(frames[i]); + // RFC 9113 §5.4.1: any other framing/protocol violation is connection-fatal. + TerminateConnection(Http2ErrorCode.ProtocolError, e.Message); } } + private void TerminateConnection(Http2ErrorCode errorCode, string reason) + { + Tracing.For("Protocol").Warning(this, + "HTTP/2: connection terminated ({0}): {1}", errorCode, reason); + EmitGoAway(_tracker.HighestAcceptedStreamId, errorCode, reason); + ShouldComplete = true; + } + private static ReadOnlySpan ConnectionPrefaceMagic => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8; private void SkipConnectionPreface(TransportBuffer buffer) @@ -139,8 +234,8 @@ private void ProcessFrame(Http2Frame frame) HandlePingFrame(ping); break; - case GoAwayFrame goAway: - HandleGoAwayFrame(goAway); + case GoAwayFrame: + HandleGoAwayFrame(); break; case RstStreamFrame rst: @@ -160,6 +255,11 @@ public void OnResponse(IFeatureCollection features) state.SetFeatures(features); + if (state.HasBodyReader && _bodyConsumptionTimeout > TimeSpan.Zero) + { + _ops.OnScheduleTimer(state.BodyConsumptionTimerKey, _bodyConsumptionTimeout); + } + var responseFeature = features.Get(); var responseBody = features.Get(); var contentLength = ExtractContentLength(responseFeature); @@ -172,27 +272,48 @@ public void OnResponse(IFeatureCollection features) EmitFrame(frames[i]); } - if (!hasBody) - { - CloseStream(streamId); - return; - } - if (responseBody is not TurboHttpResponseBodyFeature turboBody) + if (!hasBody || responseBody is not TurboHttpResponseBodyFeature turboBody) { CloseStream(streamId); return; } - var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength); - if (encoder is null) + if (turboBody.TryGetBufferedBody(out var bufferedBody)) { - CloseStream(streamId); - return; + if (bufferedBody.Length > 0) + { + var window = _flow.GetSendWindow(streamId); + if (window >= bufferedBody.Length) + { + var maxFrame = _responseEncoder.MaxFrameSize; + var remaining = bufferedBody; + while (remaining.Length > maxFrame) + { + EmitFrame(new DataFrame(streamId, remaining[..maxFrame], endStream: false)); + remaining = remaining[maxFrame..]; + } + + EmitFrame(new DataFrame(streamId, remaining, endStream: true)); + _flow.OnDataSent(streamId, bufferedBody.Length); + CloseStream(streamId); + return; + } + + SendBufferedBodyWithFlowControl(streamId, state, bufferedBody, window); + return; + } + else + { + EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: true)); + CloseStream(streamId); + return; + } } - state.InitBodyEncoder(encoder); - state.StartBodyEncoder(bodyStream, streamId, _ops.StageActor); + var bodyStream = turboBody.GetResponseStream(); + state.MarkBodyDrainActive(); + StartStreamBodyDrain(streamId, bodyStream, contentLength); + Tracing.For("Protocol").Debug(this, "HTTP/2: response body drain started (stream={0})", streamId); } private static long? ExtractContentLength(IHttpResponseFeature? responseFeature) @@ -204,12 +325,10 @@ public void OnResponse(IFeatureCollection features) foreach (var header in responseFeature.Headers) { - if (header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + if (header.Key.Equals(WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase) && + header.Value.FirstOrDefault() is { } value && ContentLengthSemantics.TryParse(value, out var length)) { - if (header.Value.FirstOrDefault() is { } value && long.TryParse(value, out var length)) - { - return length; - } + return length; } } @@ -220,120 +339,182 @@ public void OnBodyMessage(object msg) { switch (msg) { - case StreamBodyChunk chunk: - HandleOutboundBodyChunk(chunk); - break; - - case StreamBodyComplete complete: - HandleOutboundBodyComplete(complete.StreamId); + case StreamBodyReadComplete read: + HandleStreamBodyRead(read); break; - case StreamBodyFailed(var failedStreamId, var exception): + case StreamBodyReadFailed failed: Tracing.For("Protocol").Warning(this, - "HTTP/2: Response body encoding failed for stream {0}: {1}", failedStreamId, - exception.Message); - EmitRstStream(failedStreamId, Http2ErrorCode.InternalError); + "HTTP/2: Response body drain failed for stream {0}: {1}", failed.StreamId, + failed.Reason.Message); + EmitRstStream(failed.StreamId, Http2ErrorCode.InternalError); + CleanupBodyDrain(failed.StreamId); break; } } - private void HandleOutboundBodyChunk(StreamBodyChunk chunk) + private void HandleStreamBodyRead(StreamBodyReadComplete read) { - var streamId = chunk.StreamId; - if (!_streams.TryGetValue(streamId, out var state)) + if (!_streams.TryGetValue(read.StreamId, out var state)) { - chunk.Owner.Dispose(); + CleanupBodyDrain(read.StreamId); return; } - var window = _flow.GetSendWindow(streamId); - if (window >= chunk.Length) + state.IsBodyReadPending = false; + + if (read.BytesRead == 0) { - EmitFrame(new DataFrame(streamId, chunk.Owner.Memory[..chunk.Length], endStream: false)); - _flow.OnDataSent(streamId, chunk.Length); - chunk.Owner.Dispose(); + Tracing.For("Protocol").Debug(this, "HTTP/2: response body complete (stream={0})", read.StreamId); + state.MarkBodyDrainComplete(); + + if (!state.HasPendingOutbound) + { + EmitEndOfBody(read.StreamId, state); + CleanupBodyDrain(read.StreamId); + CloseStream(read.StreamId); + } + else + { + CleanupBodyDrain(read.StreamId); + } + return; } - state.EnqueueBodyChunk(chunk); - } - - private void HandleOutboundBodyComplete(int streamId) - { - if (!_streams.TryGetValue(streamId, out var state)) + Tracing.For("Protocol").Trace(this, "HTTP/2: response body chunk (stream={0}, bytes={1})", read.StreamId, read.BytesRead); + if (!_activeBodyBuffers.TryGetValue(read.StreamId, out var buffer)) { + CleanupBodyDrain(read.StreamId); return; } - state.MarkBodyEncoderComplete(); + var data = buffer.Memory[..read.BytesRead]; + var window = _flow.GetSendWindow(read.StreamId); - if (!state.HasPendingOutbound) + if (window >= read.BytesRead) + { + EmitFrame(new DataFrame(read.StreamId, data, endStream: false)); + _flow.OnDataSent(read.StreamId, read.BytesRead); + ReadNextBodyChunk(read.StreamId); + } + else if (window > 0) { - var features = state.GetFeatures(); - var trailerFeature = features?.Get(); - var hasTrailers = trailerFeature?.Trailers.Count > 0; + EmitFrame(new DataFrame(read.StreamId, data[..(int)window], endStream: false)); + _flow.OnDataSent(read.StreamId, (int)window); + var remaining = read.BytesRead - (int)window; + var owner = MemoryPool.Shared.Rent(remaining); + data[(int)window..].CopyTo(owner.Memory); + state.EnqueueBodyChunk(new StreamBodyChunk(owner, remaining)); + } + else + { + var owner = MemoryPool.Shared.Rent(read.BytesRead); + data[..read.BytesRead].CopyTo(owner.Memory); + state.EnqueueBodyChunk(new StreamBodyChunk(owner, read.BytesRead)); + } + } - if (hasTrailers) - { - EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: false)); - var trailerFrames = _responseEncoder.EncodeTrailers(streamId, trailerFeature!.Trailers); - for (var i = 0; i < trailerFrames.Count; i++) - { - EmitFrame(trailerFrames[i]); - } - } - else + private void EmitEndOfBody(int streamId, StreamState state) + { + var features = state.GetFeatures(); + var trailerFeature = features?.Get(); + var hasTrailers = trailerFeature?.Trailers.Count > 0; + + if (hasTrailers) + { + EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: false)); + var trailerFrames = _responseEncoder.EncodeTrailers(streamId, trailerFeature!.Trailers); + for (var i = 0; i < trailerFrames.Count; i++) { - EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: true)); + EmitFrame(trailerFrames[i]); } - - CloseStream(streamId); + } + else + { + EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: true)); } } public void DrainOutboundBuffer(int streamId) { - if (!_streams.TryGetValue(streamId, out var state) || !state.HasPendingOutbound) + if (!_streams.TryGetValue(streamId, out var state)) { return; } - while (state.PeekBodyChunk() is { } next) + if (!state.HasPendingOutbound) { - var window = _flow.GetSendWindow(streamId); - if (window < next.Length) + if (state.HasBodyDrain && !state.IsBodyDrainComplete && !state.IsBodyReadPending) { - break; + ReadNextBodyChunk(streamId); } - state.TryDequeueBodyChunk(out var chunk); - EmitFrame(new DataFrame(streamId, chunk!.Owner.Memory[..chunk.Length], endStream: false)); - _flow.OnDataSent(streamId, chunk.Length); - chunk.Owner.Dispose(); + return; } - if (state is { HasPendingOutbound: false, IsBodyEncoderComplete: true }) + while (state.PeekBodyChunk() is { } next) { - var features = state.GetFeatures(); - var trailerFeature = features?.Get(); - var hasTrailers = trailerFeature?.Trailers.Count > 0; + var window = _flow.GetSendWindow(streamId); + if (window <= 0) + { + break; + } - if (hasTrailers) + state.TryDequeueBodyChunk(out var chunk); + if (window >= chunk!.Length) { - EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: false)); - var trailerFrames = _responseEncoder.EncodeTrailers(streamId, trailerFeature!.Trailers); - for (var i = 0; i < trailerFrames.Count; i++) - { - EmitFrame(trailerFrames[i]); - } + EmitFrame(new DataFrame(streamId, chunk.Owner.Memory[..chunk.Length], endStream: false)); + _flow.OnDataSent(streamId, chunk.Length); + chunk.Owner.Dispose(); } else { - EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: true)); + var sendable = (int)window; + EmitFrame(new DataFrame(streamId, chunk.Owner.Memory[..sendable], endStream: false)); + _flow.OnDataSent(streamId, sendable); + var remaining = chunk.Length - sendable; + var owner = MemoryPool.Shared.Rent(remaining); + chunk.Owner.Memory.Slice(sendable, remaining).CopyTo(owner.Memory); + chunk.Owner.Dispose(); + state.PrependBodyChunk(new StreamBodyChunk(owner, remaining)); + break; } + } + if (state is { HasPendingOutbound: false, IsBodyDrainComplete: true }) + { + EmitEndOfBody(streamId, state); CloseStream(streamId); } + else if (!state.HasPendingOutbound && state.HasBodyDrain && !state.IsBodyDrainComplete && !state.IsBodyReadPending) + { + ReadNextBodyChunk(streamId); + } + } + + public void SendKeepAlivePing() + { + if (_awaitingPingAck) + { + return; + } + + _awaitingPingAck = true; + _pingSentTimestamp = Environment.TickCount64; + var data = BitConverter.GetBytes(_pingSentTimestamp); + EmitFrame(new PingFrame(data, isAck: false)); + } + + public bool IsKeepAliveTimedOut(TimeSpan timeout) + { + if (!_awaitingPingAck) + { + return false; + } + + var elapsed = Environment.TickCount64 - _pingSentTimestamp; + return elapsed >= (long)timeout.TotalMilliseconds; } public void Cleanup() @@ -364,25 +545,48 @@ private void HandleHeadersFrame(HeadersFrame headers) return; } - if (!_tracker.CanOpenStream()) + var isTrailer = _streams.TryGetValue(streamId, out var existing) && existing.GetRequestFeature() is not null; + + if (!isTrailer) { - EmitRstStream(streamId, Http2ErrorCode.RefusedStream); - return; + var acceptResult = _tracker.TryAcceptClientStream(streamId); + switch (acceptResult) + { + case StreamAcceptResult.InvalidId: + TerminateConnection(Http2ErrorCode.ProtocolError, + "RFC 9113 §5.1.1: client stream ID must be odd and non-zero."); + return; + case StreamAcceptResult.NonMonotonic: + TerminateConnection(Http2ErrorCode.ProtocolError, + "RFC 9113 §5.1.1: stream ID must be monotonically increasing."); + return; + case StreamAcceptResult.RefusedStream: + EmitRstStream(streamId, Http2ErrorCode.RefusedStream); + return; + } } - var state = GetOrCreateStreamState(streamId); + var state = isTrailer ? existing! : GetOrCreateStreamState(streamId); if (headers.EndHeaders) { - state.AppendHeader(headers.HeaderBlockFragment.Span); - DecodeAndEmitRequest(streamId, state, headers.EndStream); + if (isTrailer) + { + state.AppendHeader(headers.HeaderBlockFragment.Span, _decoderOptions.MaxHeaderBytes); + HandleTrailers(streamId, state); + } + else + { + state.AppendHeader(headers.HeaderBlockFragment.Span, _decoderOptions.MaxHeaderBytes); + DecodeAndEmitRequest(streamId, state, headers.EndStream); + } } else { - state.AppendHeader(headers.HeaderBlockFragment.Span); + state.AppendHeader(headers.HeaderBlockFragment.Span, _decoderOptions.MaxHeaderBytes); _nextContinuationStreamId = streamId; _continuationEndStream = headers.EndStream; - _ops.OnScheduleTimer(string.Concat("headers-timeout:", streamId.ToString()), TimeSpan.FromSeconds(30)); + _ops.OnScheduleTimer(state.HeadersTimeoutTimerKey, TimeSpan.FromSeconds(30)); } } @@ -402,14 +606,14 @@ private void HandleContinuationFrame(ContinuationFrame continuation) return; } - state.AppendHeader(continuation.HeaderBlockFragment.Span); + state.AppendHeader(continuation.HeaderBlockFragment.Span, _decoderOptions.MaxHeaderBytes); if (continuation.EndHeaders) { var endStream = _continuationEndStream; _nextContinuationStreamId = 0; _continuationEndStream = false; - _ops.OnCancelTimer(string.Concat("headers-timeout:", streamId.ToString())); + _ops.OnCancelTimer(state.HeadersTimeoutTimerKey); DecodeAndEmitRequest(streamId, state, endStream); } } @@ -418,6 +622,9 @@ private void HandleDataFrame(DataFrame data) { var streamId = data.StreamId; + Tracing.For("Protocol").Trace(this, "HTTP/2: DATA in (stream={0}, len={1}, endStream={2})", + streamId, data.Data.Length, data.EndStream); + if (!_streams.TryGetValue(streamId, out var state)) { EmitRstStream(streamId, Http2ErrorCode.StreamClosed); @@ -432,17 +639,19 @@ private void HandleDataFrame(DataFrame data) if (flowResult.IsConnectionViolation) { + Tracing.For("Protocol").Warning(this, "HTTP/2: connection-level flow control violation"); EmitGoAway(0, errorCode, "Flow control violation"); } else { + Tracing.For("Protocol").Warning(this, "HTTP/2: stream-level flow control violation (stream={0})", streamId); EmitRstStream(streamId, errorCode); } return; } - if (state.HasBodyDecoder) + if (state.HasBodyReader) { try { @@ -455,16 +664,15 @@ private void HandleDataFrame(DataFrame data) return; } - if (!data.Data.IsEmpty) + if (data.EndStream) { - if (!_bodyRateStates.TryGetValue(streamId, out var rateState)) - { - rateState = new BodyRateState(); - _bodyRateStates[streamId] = rateState; - _ops.OnScheduleTimer("body-rate-check", TimeSpan.FromSeconds(1)); - } + _ops.OnCancelTimer(state.BodyConsumptionTimerKey); + } - rateState.TotalBytes += data.Data.Length; + if (!data.Data.IsEmpty) + { + _requestRate.Observe(streamId, data.Data.Length, Now()); + EnsureRateTimer(); } } @@ -477,6 +685,8 @@ private void HandleDataFrame(DataFrame data) { EmitFrame(new WindowUpdateFrame(connWin.StreamId, connWin.Increment)); } + + TrySendMeasurementPing(); } private void HandleSettingsFrame(SettingsFrame settings) @@ -499,6 +709,14 @@ private void HandleSettingsFrame(SettingsFrame settings) } _responseEncoder.ApplyClientSettings(settings.Parameters); + + if (result.InitialWindowSizeChange.HasValue) + { + foreach (var streamId in _streams.Keys.ToList()) + { + DrainOutboundBuffer(streamId); + } + } } private void HandleWindowUpdateFrame(WindowUpdateFrame windowUpdate) @@ -522,6 +740,8 @@ private void HandlePingFrame(PingFrame ping) { if (ping.IsAck) { + _awaitingPingAck = false; + _flow.OnMeasurementPingAck(); return; } @@ -529,14 +749,77 @@ private void HandlePingFrame(PingFrame ping) EmitFrame(ackPing); } - private void HandleGoAwayFrame(GoAwayFrame _) + private void TrySendMeasurementPing() { + if (!_flow.ShouldSendMeasurementPing() || _awaitingPingAck) + { + return; + } + + _awaitingPingAck = true; + _pingSentTimestamp = Environment.TickCount64; + var data = BitConverter.GetBytes(_pingSentTimestamp); + _flow.OnMeasurementPingSent(); + EmitFrame(new PingFrame(data, isAck: false)); + } + + private void HandleGoAwayFrame() + { + Tracing.For("Protocol").Info(this, "HTTP/2: received GOAWAY from client"); _flow.OnGoAway(); } private void HandleRstStreamFrame(RstStreamFrame rst) { + Tracing.For("Protocol").Debug(this, "HTTP/2: received RST_STREAM (stream={0}, error={1})", rst.StreamId, rst.ErrorCode); CloseStream(rst.StreamId); + TrackStreamReset(); + } + + /// + /// RFC 9113 §5.1 / CVE-2023-44487: counts client-initiated resets within a sliding window. A client + /// that opens-and-resets streams faster than the configured budget is cut off with + /// GOAWAY(ENHANCE_YOUR_CALM) - MaxConcurrentStreams alone never saturates under this attack. + /// + private void TrackStreamReset() + { + if (_maxResetStreamsPerWindow <= 0) + { + return; + } + + var now = Now(); + if (now - _resetWindowStart >= ResetWindowMs) + { + _resetWindowStart = now; + _resetCount = 0; + } + + _resetCount++; + if (_resetCount > _maxResetStreamsPerWindow) + { + TerminateConnection(Http2ErrorCode.EnhanceYourCalm, + "RFC 9113 §5.1 / CVE-2023-44487: excessive stream resets."); + } + } + + private void HandleTrailers(int streamId, StreamState state) + { + try + { + _requestDecoder.DecodeTrailers(state); + } + catch (HttpProtocolException ex) + { + Tracing.For("Protocol") + .Warning(this, "HTTP/2: Trailer decode error on stream {0}: {1}", streamId, ex.Message); + EmitRstStream(streamId, Http2ErrorCode.ProtocolError); + state.ClearHeaderBuffer(); + return; + } + + state.ClearHeaderBuffer(); + state.FeedBody([], endStream: true); } private void DecodeAndEmitRequest(int streamId, StreamState state, bool endStream) @@ -551,13 +834,14 @@ private void DecodeAndEmitRequest(int streamId, StreamState state, bool endStrea state.InitRequestFeature(requestFeature); - _tracker.OnStreamOpened(streamId); _flow.InitStreamSendWindow(streamId); var hasBody = !endStream; if (hasBody) { - state.InitBodyDecoder(new StreamingBodyDecoder(_maxRequestBodySize)); + var queued = new QueuedBodyReader(capacity: 8); + queued.Reset(); + state.InitBodyReader(queued, _maxRequestBodySize); requestFeature.Body = state.GetBodyStream(); } @@ -569,6 +853,7 @@ private void DecodeAndEmitRequest(int streamId, StreamState state, bool endStrea features.Set(new TurboHttpResetFeature(errorCode => EmitRstStream(capturedStreamId, (Http2ErrorCode)errorCode))); + Tracing.For("Protocol").Debug(this, "HTTP/2: request dispatched (stream={0}, hasBody={1})", streamId, hasBody); _ops.OnRequest(features); } catch (HttpProtocolException ex) @@ -599,16 +884,21 @@ private StreamState GetOrCreateStreamState(int streamId) } var state = _statePool.Rent(); + state.SetTimerKeys(streamId); _streams[streamId] = state; return state; } private void CloseStream(int streamId) { - _bodyRateStates.Remove(streamId); + _requestRate.Remove(streamId); + _responseRate.Remove(streamId); + CleanupBodyDrain(streamId); if (_streams.TryGetValue(streamId, out var state)) { + _ops.OnCancelTimer(state.BodyConsumptionTimerKey); + _ops.OnCancelTimer(state.HeadersTimeoutTimerKey); _tracker.OnStreamClosed(streamId); var windowUpdateSignal = _flow.OnStreamClosed(streamId); @@ -626,18 +916,123 @@ private void CloseStream(int streamId) } } + private void SendBufferedBodyWithFlowControl(int streamId, StreamState state, ReadOnlyMemory body, long window) + { + var maxFrame = _responseEncoder.MaxFrameSize; + var sent = 0; + + if (window > 0) + { + var sendable = body[..(int)Math.Min(window, body.Length)]; + while (sendable.Length > maxFrame) + { + EmitFrame(new DataFrame(streamId, sendable[..maxFrame], endStream: false)); + sendable = sendable[maxFrame..]; + } + + EmitFrame(new DataFrame(streamId, sendable, endStream: false)); + sent = (int)Math.Min(window, body.Length); + _flow.OnDataSent(streamId, sent); + } + + var remainder = body[sent..]; + if (remainder.Length == 0) + { + EmitFrame(new DataFrame(streamId, ReadOnlyMemory.Empty, endStream: true)); + CloseStream(streamId); + return; + } + + state.MarkBodyDrainActive(); + state.MarkBodyDrainComplete(); + + while (remainder.Length > 0) + { + var chunkSize = Math.Min(remainder.Length, maxFrame); + var owner = MemoryPool.Shared.Rent(chunkSize); + remainder[..chunkSize].CopyTo(owner.Memory); + state.EnqueueBodyChunk(new StreamBodyChunk(owner, chunkSize)); + remainder = remainder[chunkSize..]; + } + + Tracing.For("Protocol").Debug(this, + "HTTP/2: buffered body flow-controlled (stream={0}, sent={1}, queued={2})", + streamId, sent, body.Length - sent); + } + + private void StartStreamBodyDrain(int streamId, Stream bodyStream, long? contentLength = null) + { + _activeBodyStreams[streamId] = bodyStream; + var maxSize = Math.Min(_bodyEncoderOptions.ChunkSize, _responseEncoder.MaxFrameSize); + var bufferSize = contentLength is > 0 and <= int.MaxValue + ? (int)Math.Min(contentLength.Value, maxSize) + : maxSize; + var buffer = MemoryPool.Shared.Rent(Math.Max(bufferSize, 256)); + _activeBodyBuffers[streamId] = buffer; + ReadNextBodyChunk(streamId); + } + + private void ReadNextBodyChunk(int streamId) + { + if (!_activeBodyStreams.TryGetValue(streamId, out var stream) || + !_activeBodyBuffers.TryGetValue(streamId, out var buffer)) + { + return; + } + + if (_streams.TryGetValue(streamId, out var state)) + { + state.IsBodyReadPending = true; + } + + var vt = stream.ReadAsync(buffer.Memory); + if (vt.IsCompletedSuccessfully) + { + HandleStreamBodyRead(new StreamBodyReadComplete(streamId, vt.Result)); + return; + } + + vt.AsTask().PipeTo( + _ops.StageActor, + success: bytesRead => new StreamBodyReadComplete(streamId, bytesRead), + failure: ex => new StreamBodyReadFailed(streamId, ex)); + } + + private void CleanupBodyDrain(int streamId) + { + if (_activeBodyBuffers.Remove(streamId, out var buffer)) + { + buffer.Dispose(); + } + + _activeBodyStreams.Remove(streamId); + } + private void EmitFrame(Http2Frame frame) { + if (frame is DataFrame d) + { + Tracing.For("Protocol").Trace(this, "HTTP/2: DATA out (stream={0}, len={1}, endStream={2})", + d.StreamId, d.Data.Length, d.EndStream); + } + + if (frame is DataFrame { Data.Length: > 0 } df) + { + _responseRate.Observe(df.StreamId, df.Data.Length, Now()); + EnsureRateTimer(); + } + var totalSize = frame.SerializedSize; var buf = TransportBuffer.Rent(totalSize); var span = buf.FullMemory.Span; frame.WriteTo(ref span); buf.Length = totalSize; - _ops.OnOutbound(new TransportData(buf)); + _ops.OnOutbound(TransportData.Rent(buf)); } public void EmitRstStream(int streamId, Http2ErrorCode errorCode) { + Tracing.For("Protocol").Debug(this, "HTTP/2: RST_STREAM (stream={0}, error={1})", streamId, errorCode); EmitFrame(new RstStreamFrame(streamId, errorCode)); CloseStream(streamId); } @@ -651,56 +1046,26 @@ public void EmitGoAway(int lastStreamId, Http2ErrorCode errorCode, string? reaso EmitFrame(new GoAwayFrame(lastStreamId, errorCode, debugData)); } - public void CheckBodyRates(int minDataRate, TimeSpan gracePeriod) + public void CheckDataRates() { - var now = Environment.TickCount64; - var streamsToReset = new List(); - - foreach (var (streamId, state) in _bodyRateStates) - { - var elapsedMs = now - state.LastCheckTimestamp; - if (elapsedMs < 500) - { - continue; - } - - var elapsedSeconds = elapsedMs / 1000.0; - var bytesTransferred = state.TotalBytes - state.LastCheckBytes; - var rate = bytesTransferred / elapsedSeconds; + var now = Now(); + var violations = new List(); - state.LastCheckBytes = state.TotalBytes; - state.LastCheckTimestamp = now; - - if (rate < minDataRate) - { - if (!state.InGracePeriod) - { - state.InGracePeriod = true; - state.GracePeriodStartTimestamp = now; - } - else - { - var graceElapsedMs = now - state.GracePeriodStartTimestamp; - if (graceElapsedMs > (long)gracePeriod.TotalMilliseconds) - { - streamsToReset.Add(streamId); - } - } - } - else - { - state.InGracePeriod = false; - } - } + _requestRate.Check(now, violations); + _responseRate.Check(now, violations); - foreach (var streamId in streamsToReset) + var violationSet = new HashSet(violations); + foreach (var streamId in violationSet) { - EmitRstStream(streamId, Http2ErrorCode.EnhanceYourCalm); + Tracing.For("Protocol").Warning(this, "HTTP/2: data rate violation (stream={0})", streamId); + EmitRstStream((int)streamId, Http2ErrorCode.EnhanceYourCalm); } - if (_bodyRateStates.Count > 0) + if (_requestRate.Count > 0 || _responseRate.Count > 0) { - _ops.OnScheduleTimer("body-rate-check", TimeSpan.FromSeconds(1)); + _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); } } + + private void EnsureRateTimer() => _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs index 3ecf4c7a4..501faf6ba 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol.Syntax.Http2.Options; using TurboHTTP.Server; using TurboHTTP.Streams.Stages.Server; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http2.Server; @@ -11,63 +11,42 @@ internal sealed class Http2ServerStateMachine : IServerStateMachine private const string DrainBodyPrefix = "drain-body:"; private const string HeadersTimeoutPrefix = "headers-timeout:"; private const string KeepAliveTimeout = "keep-alive-timeout"; - private const string BodyRateCheck = "body-rate-check:"; + private const string DataRateCheck = "data-rate-check"; + private const string BodyConsumptionPrefix = "body-consumption:"; + private const string KeepAlivePingTimer = "keep-alive-ping"; + private const string KeepAlivePingTimeoutTimer = "keep-alive-ping-timeout"; private readonly IServerStageOperations _ops; private readonly Http2ServerSessionManager _sessionManager; private readonly TimeSpan _keepAliveTimeout; - private readonly TimeSpan _requestHeadersTimeout; - private readonly int _minBodyDataRate; - private readonly TimeSpan _bodyRateGracePeriod; + private readonly TimeSpan _keepAlivePingDelay; + private readonly TimeSpan _keepAlivePingTimeout; private int _activeStreamCount; + private bool KeepAlivePingEnabled => _keepAlivePingDelay != Timeout.InfiniteTimeSpan; + public bool CanAcceptResponse => _sessionManager.ActiveStreamCount > 0; - public bool ShouldComplete => false; + public bool ShouldComplete => _sessionManager.ShouldComplete; public int MaxQueuedRequests => _sessionManager.MaxConcurrentStreams; - public Http2ServerStateMachine(TurboServerOptions options, IServerStageOperations ops) + public Http2ServerStateMachine(Http2ConnectionOptions options, IServerStageOperations ops) { _ops = ops ?? throw new ArgumentNullException(nameof(ops)); ArgumentNullException.ThrowIfNull(options); - var shared = SharedHttpOptions.Default with - { - MaxBufferedBodySize = options.BodyBufferThreshold, - MaxStreamedBodySize = options.Http2.MaxRequestBodySize, - MaxHeaderBytes = options.Http2.MaxHeaderListSize, - }; + _sessionManager = new Http2ServerSessionManager(options, ops); - var encoderOpts = new Http2ServerEncoderOptions - { - Shared = shared, - HeaderTableSize = options.Http2.HeaderTableSize, - MaxFrameSize = options.Http2.MaxFrameSize, - }; - - var decoderOpts = new Http2ServerDecoderOptions - { - Shared = shared, - MaxConcurrentStreams = options.Http2.MaxConcurrentStreams, - MaxFieldSectionSize = options.Http2.MaxHeaderListSize, - }; - - _sessionManager = new Http2ServerSessionManager( - encoderOpts, - decoderOpts, - ops, - options); - - _keepAliveTimeout = options.Http2.KeepAliveTimeout; - _requestHeadersTimeout = options.Http2.RequestHeadersTimeout; - _minBodyDataRate = options.Http2.MinRequestBodyDataRate; - _bodyRateGracePeriod = options.Http2.MinRequestBodyDataRateGracePeriod; + _keepAliveTimeout = options.Limits.KeepAliveTimeout; + _keepAlivePingDelay = options.KeepAlivePingDelay; + _keepAlivePingTimeout = options.KeepAlivePingTimeout; } public void PreStart() { _sessionManager.PreStart(); - _ops.OnScheduleTimer("keep-alive-timeout", _keepAliveTimeout); + _ops.OnScheduleTimer(KeepAliveTimeout, _keepAliveTimeout); + ScheduleKeepAlivePing(); } public void DecodeClientData(ITransportInbound data) @@ -79,16 +58,20 @@ public void DecodeClientData(ITransportInbound data) _sessionManager.DecodeClientData(buffer); + ResetKeepAlivePingTimer(); + var streamCount = _sessionManager.ActiveStreamCount; switch (streamCount) { case > 0 when _activeStreamCount == 0: _activeStreamCount = streamCount; _ops.OnCancelTimer(KeepAliveTimeout); + Tracing.For("Protocol").Debug(this, "HTTP/2: first stream opened, keep-alive timer cancelled"); break; case 0 when _activeStreamCount > 0: _activeStreamCount = 0; _ops.OnScheduleTimer(KeepAliveTimeout, _keepAliveTimeout); + Tracing.For("Protocol").Debug(this, "HTTP/2: all streams closed, keep-alive timer scheduled"); break; default: _activeStreamCount = streamCount; @@ -106,7 +89,28 @@ public void OnTimerFired(string name) { if (name == KeepAliveTimeout) { + Tracing.For("Protocol").Info(this, "HTTP/2: keep-alive timeout - sending GOAWAY"); _sessionManager.EmitGoAway(0, Http2ErrorCode.NoError, "Keep-alive timeout"); + _sessionManager.ShouldComplete = true; + return; + } + + if (name == KeepAlivePingTimer) + { + Tracing.For("Protocol").Trace(this, "HTTP/2: sending keep-alive PING"); + _sessionManager.SendKeepAlivePing(); + ScheduleKeepAlivePingTimeout(); + return; + } + + if (name == KeepAlivePingTimeoutTimer) + { + if (_sessionManager.IsKeepAliveTimedOut(_keepAlivePingTimeout)) + { + Tracing.For("Protocol").Info(this, "HTTP/2: keep-alive PING timeout - sending GOAWAY"); + _sessionManager.EmitGoAway(0, Http2ErrorCode.NoError, "Keep-alive PING timeout"); + _sessionManager.ShouldComplete = true; + } return; } @@ -130,13 +134,45 @@ public void OnTimerFired(string name) return; } - if (name == BodyRateCheck) + if (name == DataRateCheck) { - _sessionManager.CheckBodyRates(_minBodyDataRate, _bodyRateGracePeriod); + _sessionManager.CheckDataRates(); + return; + } + + if (name.StartsWith(BodyConsumptionPrefix) && + int.TryParse(name.AsSpan(BodyConsumptionPrefix.Length), out var consumptionStreamId)) + { + _sessionManager.EmitRstStream(consumptionStreamId, Http2ErrorCode.Cancel); } } public void OnBodyMessage(object msg) => _sessionManager.OnBodyMessage(msg); + private void ScheduleKeepAlivePing() + { + if (KeepAlivePingEnabled) + { + _ops.OnScheduleTimer(KeepAlivePingTimer, _keepAlivePingDelay); + } + } + + private void ScheduleKeepAlivePingTimeout() + { + if (KeepAlivePingEnabled) + { + _ops.OnScheduleTimer(KeepAlivePingTimeoutTimer, _keepAlivePingTimeout); + } + } + + private void ResetKeepAlivePingTimer() + { + if (KeepAlivePingEnabled) + { + _ops.OnCancelTimer(KeepAlivePingTimeoutTimer); + ScheduleKeepAlivePing(); + } + } + public void Cleanup() => _sessionManager.Cleanup(); } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs b/src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs index a6bf03c26..c04e1647d 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs @@ -1,7 +1,6 @@ using System.Buffers; -using Akka.Actor; using Microsoft.AspNetCore.Http.Features; -using TurboHTTP.Protocol.Multiplexed.Body; +using TurboHTTP.Protocol.Body; using TurboHTTP.Server.Context.Features; namespace TurboHTTP.Protocol.Syntax.Http2; @@ -12,8 +11,6 @@ namespace TurboHTTP.Protocol.Syntax.Http2; /// internal sealed class StreamState { - private readonly MemoryPool _pool = MemoryPool.Shared; - private IMemoryOwner? _headerOwner; private Memory _headerBuffer; private int _headerLength; @@ -22,21 +19,41 @@ internal sealed class StreamState private IFeatureCollection? _features; private List<(string Name, string Value)>? _contentHeaders; private Dictionary? _pseudoHeaders; - private IBodyDecoder? _bodyDecoder; - private IBodyEncoder? _bodyEncoder; - private Queue>? _outboundBuffer; + private IBodyReader? _bodyReader; + private long _maxBodySize; + private long _totalBodyBytes; + private Queue? _outboundBuffer; + + public string BodyConsumptionTimerKey { get; private set; } = ""; + public string HeadersTimeoutTimerKey { get; private set; } = ""; + + public void SetTimerKeys(int streamId) + { + var idStr = streamId.ToString(); + BodyConsumptionTimerKey = string.Concat("body-consumption:", idStr); + HeadersTimeoutTimerKey = string.Concat("headers-timeout:", idStr); + } public bool HasResponse => _response is not null; public bool HasContentHeaders => _contentHeaders is not null; - public bool HasBodyDecoder => _bodyDecoder is not null; + public bool HasBodyReader => _bodyReader is not null; - public bool HasBodyEncoder => _bodyEncoder is not null; + public bool HasBodyDrain { get; private set; } public bool HasPendingOutbound => _outboundBuffer is { Count: > 0 }; - public bool IsBodyEncoderComplete { get; private set; } + public bool IsBodyDrainComplete { get; private set; } + + public bool IsBodyReadPending { get; set; } + + /// + /// Declared Content-Length of the body being fed, when known. When set, an END_STREAM + /// arriving before (or after) exactly this many bytes faults the body reader instead of + /// completing it, so a truncated body surfaces as an error rather than silent success. + /// + public long? ExpectedBodyLength { get; set; } public bool IsRemoteClosed { get; private set; } @@ -45,6 +62,11 @@ public ReadOnlySpan GetHeaderSpan() return _headerBuffer[.._headerLength].Span; } + public void ClearHeaderBuffer() + { + _headerLength = 0; + } + public void InitResponse(HttpResponseMessage response) { _response = response; @@ -80,24 +102,12 @@ public void AddPseudoHeader(string name, string value) _pseudoHeaders[name] = value; } - public string GetPseudoHeader(string name) - { - if (_pseudoHeaders?.TryGetValue(name, out var value) == true) - { - return value; - } - - throw new InvalidOperationException($"Pseudo-header '{name}' not found."); - } - public void AddContentHeader(string name, string value) { _contentHeaders ??= []; _contentHeaders.Add((name, value)); } - public IReadOnlyList<(string Name, string Value)>? ContentHeaders => _contentHeaders; - public void ApplyContentHeadersTo(HttpContent content) { if (_contentHeaders is null) @@ -111,74 +121,111 @@ public void ApplyContentHeadersTo(HttpContent content) } } - public void InitBodyDecoder(IBodyDecoder decoder) + public void InitBodyReader(IBodyReader reader, long maxBodySize = long.MaxValue) { - _bodyDecoder = decoder; + _bodyReader = reader; + _maxBodySize = maxBodySize; + _totalBodyBytes = 0; } - public void DetachBodyDecoder() + public void DetachBodyReader() { - _bodyDecoder = null; + _bodyReader = null; } public void FeedBody(ReadOnlySpan data, bool endStream) { - if (HasBodyDecoder) + if (!data.IsEmpty) + { + _totalBodyBytes += data.Length; + if (_totalBodyBytes > _maxBodySize) + { + throw new HttpProtocolException( + string.Concat("Request body size ", _totalBodyBytes.ToString(), " exceeds limit ", + _maxBodySize.ToString(), ".")); + } + } + + if (_bodyReader is IBufferedBodyReader buffered) { - _bodyDecoder?.Feed(data, endStream); + if (!data.IsEmpty) + { + buffered.Feed(data); + } + + if (endStream) + { + buffered.MarkComplete(); + } + + return; + } + + if (_bodyReader is IStreamingBodyReader streaming) + { + if (!data.IsEmpty) + { + streaming.TryEnqueue(data); + } + + if (endStream) + { + if (ExpectedBodyLength is { } expected && _totalBodyBytes != expected) + { + streaming.Fault(new HttpRequestException( + $"Response body ended after {_totalBodyBytes} bytes but Content-Length declared {expected}.")); + } + else + { + streaming.Complete(); + } + } } } public Stream GetBodyStream() { - if (_bodyDecoder is null) + if (_bodyReader is null) { - throw new InvalidOperationException("No body decoder has been initialized."); + throw new InvalidOperationException("No body reader has been initialized."); } - return _bodyDecoder.GetBodyStream(); + return _bodyReader.AsStream(); } public void AbortBody() { - _bodyDecoder?.Abort(); + if (_bodyReader is IStreamingBodyReader streaming) + { + streaming.Fault(new OperationCanceledException()); + } + + _bodyReader?.Dispose(); } - public void InitBodyEncoder(IBodyEncoder encoder) + public void MarkBodyDrainActive() { - _bodyEncoder = encoder; + HasBodyDrain = true; + IsBodyDrainComplete = false; } - public void StartBodyEncoder(Stream bodyStream, int streamId, IActorRef stageActor) + public void MarkBodyDrainComplete() { - if (_bodyEncoder is null) - { - throw new InvalidOperationException("No body encoder has been initialized."); - } - - _bodyEncoder.Start(bodyStream, msg => - { - var tagged = msg switch - { - OutboundBodyChunk chunk => new StreamBodyChunk(streamId, chunk.Owner, chunk.Length), - OutboundBodyComplete => new StreamBodyComplete(streamId), - OutboundBodyFailed failed => new StreamBodyFailed(streamId, failed.Reason), - _ => msg - }; - - stageActor.Tell(tagged); - }); + IsBodyDrainComplete = true; } - public void EnqueueBodyChunk(StreamBodyChunk chunk) + public long PendingOutboundBytes { get; private set; } + + public void EnqueueBodyChunk(StreamBodyChunk chunk) { - _outboundBuffer ??= new Queue>(); + _outboundBuffer ??= new Queue(); _outboundBuffer.Enqueue(chunk); + PendingOutboundBytes += chunk.Length; } - public void PrependBodyChunk(StreamBodyChunk chunk) + public void PrependBodyChunk(StreamBodyChunk chunk) { - _outboundBuffer ??= new Queue>(); + _outboundBuffer ??= new Queue(); var existing = _outboundBuffer.ToArray(); _outboundBuffer.Clear(); _outboundBuffer.Enqueue(chunk); @@ -186,11 +233,8 @@ public void PrependBodyChunk(StreamBodyChunk chunk) { _outboundBuffer.Enqueue(item); } - } - public void MarkBodyEncoderComplete() - { - IsBodyEncoderComplete = true; + PendingOutboundBytes += chunk.Length; } public void MarkRemoteClosed() @@ -198,11 +242,12 @@ public void MarkRemoteClosed() IsRemoteClosed = true; } - public bool TryDequeueBodyChunk(out StreamBodyChunk? chunk) + public bool TryDequeueBodyChunk(out StreamBodyChunk? chunk) { if (_outboundBuffer is { Count: > 0 }) { chunk = _outboundBuffer.Dequeue(); + PendingOutboundBytes -= chunk.Length; return true; } @@ -210,12 +255,11 @@ public bool TryDequeueBodyChunk(out StreamBodyChunk? chunk) return false; } - public StreamBodyChunk? PeekBodyChunk() + public StreamBodyChunk? PeekBodyChunk() { return _outboundBuffer is { Count: > 0 } ? _outboundBuffer.Peek() : null; } - public void Reset() { _headerOwner?.Dispose(); @@ -227,14 +271,18 @@ public void Reset() _features = null; _contentHeaders = null; _pseudoHeaders = null; - _bodyDecoder?.Dispose(); - _bodyDecoder = null; - _bodyEncoder?.Dispose(); - _bodyEncoder = null; + _bodyReader?.Dispose(); + _bodyReader = null; + HasBodyDrain = false; + IsBodyDrainComplete = false; + IsBodyReadPending = false; + ExpectedBodyLength = null; DisposeOutboundBuffer(); _outboundBuffer = null; - IsBodyEncoderComplete = false; + PendingOutboundBytes = 0; IsRemoteClosed = false; + BodyConsumptionTimerKey = ""; + HeadersTimeoutTimerKey = ""; } public void AppendHeader(ReadOnlySpan data) @@ -249,6 +297,29 @@ public void AppendHeader(ReadOnlySpan data) _headerLength += data.Length; } + /// + /// Appends a header-block fragment, rejecting the stream's accumulated (still-compressed) header + /// block once it exceeds . RFC 9113 §6.10 / CVE-2024-27316: + /// bounds a HEADERS+CONTINUATION flood before the block is buffered and HPACK-decoded. Using the + /// decoded-size limit as the compressed-size ceiling is conservative - HPACK never expands below + /// the compressed input for valid traffic, so legitimate requests are unaffected. + /// + public void AppendHeader(ReadOnlySpan data, int maxAccumulatedBytes) + { + if (data.IsEmpty) + { + return; + } + + if ((long)_headerLength + data.Length > maxAccumulatedBytes) + { + throw new HttpProtocolException( + $"RFC 9113 §6.10: accumulated header block exceeds the maximum of {maxAccumulatedBytes} bytes."); + } + + AppendHeader(data); + } + private void DisposeOutboundBuffer() { if (_outboundBuffer is null) @@ -260,6 +331,8 @@ private void DisposeOutboundBuffer() { _outboundBuffer.Dequeue().Owner.Dispose(); } + + PendingOutboundBytes = 0; } private void EnsureHeaderCapacity(int required) @@ -272,7 +345,7 @@ private void EnsureHeaderCapacity(int required) private void RentNewHeaderBuffer(int size) { - var newOwner = _pool.Rent(size); + var newOwner = MemoryPool.Shared.Rent(size); if (_headerOwner != null) { _headerBuffer.Span.CopyTo(newOwner.Memory.Span); diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/StreamTracker.cs b/src/TurboHTTP/Protocol/Syntax/Http2/StreamTracker.cs index 015975a70..906f6228d 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/StreamTracker.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/StreamTracker.cs @@ -2,22 +2,48 @@ namespace TurboHTTP.Protocol.Syntax.Http2; -internal sealed class StreamTracker : IStreamTracker +internal enum StreamAcceptResult { - private int _nextStreamId; - private readonly HashSet _activeStreamIds = []; + Accepted, + InvalidId, + NonMonotonic, + RefusedStream +} - public StreamTracker(int initialNextStreamId = 1, int maxConcurrentStreams = 100) - { - _nextStreamId = initialNextStreamId; - MaxConcurrentStreams = maxConcurrentStreams; - } +internal sealed class StreamTracker(int initialNextStreamId, int maxConcurrentStreams) : IStreamTracker +{ + private int _nextStreamId = initialNextStreamId; + private readonly HashSet _activeStreamIds = []; + private int _highestAcceptedStreamId; public int ActiveStreamCount { get; private set; } - public int MaxConcurrentStreams { get; private set; } + public int MaxConcurrentStreams { get; private set; } = maxConcurrentStreams; + public int HighestAcceptedStreamId => _highestAcceptedStreamId; public bool CanOpenStream() => ActiveStreamCount < MaxConcurrentStreams; + public StreamAcceptResult TryAcceptClientStream(int streamId) + { + if (streamId == 0 || (streamId & 1) == 0) + { + return StreamAcceptResult.InvalidId; + } + + if (streamId <= _highestAcceptedStreamId) + { + return StreamAcceptResult.NonMonotonic; + } + + if (!CanOpenStream()) + { + return StreamAcceptResult.RefusedStream; + } + + _highestAcceptedStreamId = streamId; + OnStreamOpened(streamId); + return StreamAcceptResult.Accepted; + } + public int AllocateStreamId() { var id = _nextStreamId; @@ -52,5 +78,6 @@ public void Reset() _activeStreamIds.Clear(); ActiveStreamCount = 0; _nextStreamId = 1; + _highestAcceptedStreamId = 0; } } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/WindowScaler.cs b/src/TurboHTTP/Protocol/Syntax/Http2/WindowScaler.cs new file mode 100644 index 000000000..96624e72d --- /dev/null +++ b/src/TurboHTTP/Protocol/Syntax/Http2/WindowScaler.cs @@ -0,0 +1,30 @@ +namespace TurboHTTP.Protocol.Syntax.Http2; + +/// +/// Pure decision function for HTTP/2 adaptive receive-window growth. +/// Mirrors SocketsHttpHandler's BDP heuristic: grow when the connection's measured +/// bandwidth-delay product exceeds the current window scaled by a multiplier. +/// Holds no window state - the caller owns the window. +/// +internal sealed class WindowScaler(int maxWindow, double multiplier) +{ + public int MaxWindow => maxWindow; + + /// + /// Returns the new window size (>= currentWindow), doubling up to the cap when the link is + /// keeping the current window saturated. Returns currentWindow unchanged when RTT is unknown, + /// the sample is degenerate, or growth is not warranted. + /// + public int ComputeNewWindow(int currentWindow, long deliveredBytes, TimeSpan elapsed, TimeSpan minRtt) + { + if (currentWindow >= maxWindow || minRtt <= TimeSpan.Zero || elapsed <= TimeSpan.Zero || deliveredBytes <= 0) + { + return currentWindow; + } + + var bdpTerm = (double)deliveredBytes * minRtt.Ticks; + var windowTerm = (double)currentWindow * elapsed.Ticks * multiplier; + + return bdpTerm > windowTerm ? Math.Min(maxWindow, currentWindow * 2) : currentWindow; + } +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientDecoder.cs index c2c44f125..36715933f 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientDecoder.cs @@ -6,12 +6,10 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Client; internal sealed class Http3ClientDecoder { - private static readonly HttpContent SharedEmptyContent = new ByteArrayContent([]); - private readonly QpackTableSync _tableSync; private readonly int _maxFieldSectionSize; - public Http3ClientDecoder(QpackTableSync tableSync, int maxFieldSectionSize = int.MaxValue) + public Http3ClientDecoder(QpackTableSync tableSync, int maxFieldSectionSize) { ArgumentNullException.ThrowIfNull(tableSync); _tableSync = tableSync; @@ -73,7 +71,7 @@ public bool AssembleHeaders(IReadOnlyList<(string Name, string Value)> headers, response.Headers.TryAddWithoutValidation(h.Name, h.Value); if (string.Equals(h.Name, WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase) && - long.TryParse(h.Value, out var cl)) + ContentLengthSemantics.TryParse(h.Value, out var cl)) { state.ExpectedContentLength = cl; } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientEncoder.cs index f2decc350..d80f1b214 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientEncoder.cs @@ -5,7 +5,7 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Client; /// -/// RFC 9114 §4.1 — Encodes HTTP request messages as HTTP/3 frame sequences. +/// RFC 9114 §4.1 - Encodes HTTP request messages as HTTP/3 frame sequences. /// Uses QPACK (RFC 9204) for header compression instead of HPACK. /// /// Unlike HTTP/2, HTTP/3 frames have no stream identifier (QUIC provides that) @@ -17,13 +17,10 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Client; /// internal sealed class Http3ClientEncoder { - // Tracks MemoryPool rentals from the previous Encode() call so they can be - // disposed once the caller has consumed the frame list (contract: callers consume - // frames before the next Encode() call). - private readonly List> _rentedOwners = new(4); private readonly List _reusableFrames = new(4); private readonly List<(string Name, string Value)> _reusableHeaders = new(16); private readonly QpackTableSync _tableSync; + private IMemoryOwner? _qpackBuffer; /// /// Creates a new HTTP/3 request encoder. @@ -61,10 +58,6 @@ public IReadOnlyList Encode(HttpRequestMessage request) ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request.RequestUri); - // Dispose MemoryPool rentals from the previous Encode() call. - // Safe: callers consume the frame list before calling Encode() again. - ReturnRentedBuffers(); - // RFC 9114 §10.3: Validate origin before encoding OriginValidator.Validate(request.RequestUri, isConnect: request.Method == HttpMethod.Connect); @@ -73,12 +66,10 @@ public IReadOnlyList Encode(HttpRequestMessage request) ValidatePseudoHeaders(_reusableHeaders); FieldValidator.Validate(_reusableHeaders); - // QPACK encode directly into a MemoryPool-rented buffer - var qpackOwner = MemoryPool.Shared.Rent(4 * 1024); - _rentedOwners.Add(qpackOwner); - var qpackWriter = SpanWriter.Create(qpackOwner.Memory.Span); + _qpackBuffer ??= MemoryPool.Shared.Rent(4 * 1024); + var qpackWriter = SpanWriter.Create(_qpackBuffer.Memory.Span); var qpackBytesWritten = _tableSync.Encoder.Encode(_reusableHeaders, ref qpackWriter); - var headerBlock = qpackOwner.Memory[..qpackBytesWritten]; + var headerBlock = _qpackBuffer.Memory[..qpackBytesWritten]; var peerLimit = _tableSync.RemoteMaxFieldSectionSize; if (qpackBytesWritten > peerLimit) @@ -116,18 +107,10 @@ public IReadOnlyList Encode(HttpRequestMessage request) return (owner, n); } - /// - /// Disposes all MemoryPool rentals from the previous Encode() call. - /// Must be called before reusing the frame list. - /// - private void ReturnRentedBuffers() + public void Dispose() { - foreach (var owner in _rentedOwners) - { - owner.Dispose(); - } - - _rentedOwners.Clear(); + _qpackBuffer?.Dispose(); + _qpackBuffer = null; } /// diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs index 7a3c5ca90..509f37687 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs @@ -1,16 +1,19 @@ using System.Buffers; +using Akka.Actor; using Servus.Akka.Transport; using TurboHTTP.Client; using TurboHTTP.Internal; using TurboHTTP.Protocol.Multiplexed; -using TurboHTTP.Protocol.Multiplexed.Body; using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http3.Client; +internal readonly record struct StreamBodyReadComplete(long StreamId, int BytesRead); +internal readonly record struct StreamBodyReadFailed(long StreamId, Exception Reason); + internal sealed class Http3ClientSessionManager { private readonly Http3ClientEncoderOptions _encoderOptions; @@ -23,17 +26,17 @@ internal sealed class Http3ClientSessionManager private readonly StreamManager _streamManager; private readonly Http3ClientEncoder _requestEncoder; - private readonly Http3ClientDecoder _responseDecoder; private readonly QpackTableSync _tableSync; private readonly Dictionary _correlationMap = new(); + private readonly Dictionary _activeBodyStreams = new(); + private readonly Dictionary> _activeBodyBuffers = new(); private bool _controlPrefaceSent; private bool _transportConnected; private readonly List _preConnectBuffer = []; public bool CanOpenStream => _tracker.CanOpenStream(); - public bool GoAwayReceived { get; private set; } public bool HasInFlightRequests => _correlationMap.Count > 0 || _streamManager.HasInFlightRequests; public RequestEndpoint Endpoint { get; private set; } @@ -57,9 +60,9 @@ public Http3ClientSessionManager( configuredEncoderLimit: encoderOptions.QpackMaxTableCapacity); _requestEncoder = new Http3ClientEncoder(_tableSync); - _responseDecoder = new Http3ClientDecoder(_tableSync, decoderOptions.MaxFieldSectionSize); - _qpackStreamManager = new QpackStreamManager(ops, _requestEncoder, _responseDecoder, _tableSync); - _streamManager = new StreamManager(ops, _responseDecoder, _tableSync) + var responseDecoder = new Http3ClientDecoder(_tableSync, decoderOptions.MaxFieldSectionSize); + _qpackStreamManager = new QpackStreamManager(ops, _requestEncoder, responseDecoder, _tableSync); + _streamManager = new StreamManager(ops, responseDecoder, _tableSync, _options.MaxStreamedResponseBodySize ?? long.MaxValue) { OnStreamClosedCallback = OnStreamClosed }; @@ -67,6 +70,7 @@ public Http3ClientSessionManager( private void OnStreamClosed(long streamId) { + _tracker.OnStreamClosed(streamId); _correlationMap.Remove(streamId); } @@ -120,48 +124,84 @@ public void EncodeRequest(HttpRequestMessage request) var contentLength = request.Content?.Headers.ContentLength; var bodyStream = request.Content?.ReadAsStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength); - if (encoder is null) + + if (bodyStream is MemoryStream ms && ms.TryGetBuffer(out var segment)) + { + var pos = (int)ms.Position; + var available = segment.Count - pos; + if (available > 0) + { + var dataFrame = new DataFrame(segment.AsMemory(pos, available)); + EmitSerializedFrame(dataFrame, streamId); + EmitOutbound(new CompleteWrites(StreamTarget.FromId(streamId))); + return; + } + } + + if (contentLength is > 0 and { } knownLength + && knownLength <= _options.Http3.MaxBufferedRequestBodySize + && TrySerializeBodyDirect(request.Content!, streamId, (int)knownLength)) { - EmitOutbound(new CompleteWrites(StreamTarget.FromId(streamId))); return; } var state = _streamManager.GetOrCreateStreamState(streamId); - state.InitBodyEncoder(encoder); - state.StartBodyEncoder(bodyStream!, streamId, _ops.StageActor); + state.MarkBodyDrainActive(); + StartStreamBodyDrain(streamId, bodyStream!, contentLength); } public void OnBodyMessage(object msg) { switch (msg) { - case StreamBodyChunk chunk: - HandleOutboundBodyChunk(chunk); + case StreamBodyReadComplete read: + HandleStreamBodyRead(read); break; - case StreamBodyComplete complete: - EmitOutbound(new CompleteWrites(StreamTarget.FromId(complete.StreamId))); - break; - - case StreamBodyFailed failed: + case StreamBodyReadFailed failed: Tracing.For("Protocol").Warning(this, - "HTTP/3: Body encoding failed for stream {0}: {1}", failed.StreamId, failed.Reason.Message); + "HTTP/3: Body drain failed for stream {0}: {1}", failed.StreamId, failed.Reason.Message); EmitOutbound(new ResetStream(failed.StreamId)); + CleanupBodyDrain(failed.StreamId); break; } } - private void HandleOutboundBodyChunk(StreamBodyChunk chunk) + private void HandleStreamBodyRead(StreamBodyReadComplete read) { - var dataFrame = new DataFrame(chunk.Owner.Memory[..chunk.Length]); - EmitSerializedFrame(dataFrame, chunk.StreamId); - chunk.Owner.Dispose(); + var state = _streamManager.TryGetStreamState(read.StreamId); + if (state is null) + { + CleanupBodyDrain(read.StreamId); + return; + } + + if (read.BytesRead == 0) + { + Tracing.For("Protocol").Debug(this, "HTTP/3: request body complete (stream={0})", read.StreamId); + EmitOutbound(new CompleteWrites(StreamTarget.FromId(read.StreamId))); + state.MarkBodyDrainComplete(); + CleanupBodyDrain(read.StreamId); + return; + } + + Tracing.For("Protocol").Trace(this, "HTTP/3: request body chunk (stream={0}, bytes={1})", read.StreamId, read.BytesRead); + if (!_activeBodyBuffers.TryGetValue(read.StreamId, out var buffer)) + { + CleanupBodyDrain(read.StreamId); + return; + } + + var data = buffer.Memory[..read.BytesRead]; + + var dataFrame = new DataFrame(data); + EmitSerializedFrame(dataFrame, read.StreamId); + ReadNextBodyChunk(read.StreamId); } public void OpenCriticalStreams() { - _qpackStreamManager.OpenCriticalStreams(EmitOutbound); + QpackStreamManager.OpenCriticalStreams(EmitOutbound); } public MultiplexedData? TryBuildControlPreface() @@ -204,7 +244,7 @@ public IReadOnlyList DecodeServerData(TransportBuffer buffer, long s public void AssembleResponse(Http3Frame frame, long streamId) { - _streamManager.AssembleResponse(frame, streamId, Endpoint); + _streamManager.AssembleResponse(frame, streamId); } public void FlushPendingResponse(long streamId) @@ -263,6 +303,32 @@ public List SnapshotAndClearCorrelations() return snapshot; } + public bool TryCancelStream(HttpRequestMessage request) + { + long streamId = -1; + foreach (var (id, req) in _correlationMap) + { + if (ReferenceEquals(req, request)) + { + streamId = id; + break; + } + } + + if (streamId < 0) + { + return false; + } + + EmitOutbound(new ResetStream(streamId, 0x10C)); + _correlationMap.Remove(streamId); + request.Fail(new OperationCanceledException("Request cancelled by caller.")); + CleanupBodyDrain(streamId); + _tracker.OnStreamClosed(streamId); + + return true; + } + public void ResetConnectionState() { _tracker.Reset(); @@ -274,6 +340,11 @@ public void ResetConnectionState() public void Cleanup() { + foreach (var streamId in _activeBodyStreams.Keys.ToList()) + { + CleanupBodyDrain(streamId); + } + _streamManager.Dispose(); foreach (var item in _preConnectBuffer) @@ -313,6 +384,63 @@ private void FlushPreConnectBuffer() _preConnectBuffer.Clear(); } + private bool TrySerializeBodyDirect(HttpContent content, long streamId, int bodyLength) + { + var pool = ArrayPool.Shared; + var bodyArray = pool.Rent(bodyLength); + try + { + using var ms = new MemoryStream(bodyArray, 0, bodyLength, writable: true); + content.CopyTo(ms, null, CancellationToken.None); + } + catch (NotSupportedException) + { + pool.Return(bodyArray); + return false; + } + + var dataFrame = new DataFrame(new ReadOnlyMemory(bodyArray, 0, bodyLength)); + EmitSerializedFrame(dataFrame, streamId); + pool.Return(bodyArray); + EmitOutbound(new CompleteWrites(StreamTarget.FromId(streamId))); + return true; + } + + private void StartStreamBodyDrain(long streamId, Stream bodyStream, long? contentLength = null) + { + _activeBodyStreams[streamId] = bodyStream; + var bufferSize = contentLength is > 0 and <= int.MaxValue + ? (int)Math.Min(contentLength.Value, _options.RequestBodyChunkSize) + : _options.RequestBodyChunkSize; + var buffer = MemoryPool.Shared.Rent(Math.Max(bufferSize, 256)); + _activeBodyBuffers[streamId] = buffer; + ReadNextBodyChunk(streamId); + } + + private void ReadNextBodyChunk(long streamId) + { + if (!_activeBodyStreams.TryGetValue(streamId, out var stream) || + !_activeBodyBuffers.TryGetValue(streamId, out var buffer)) + { + return; + } + + stream.ReadAsync(buffer.Memory).AsTask().PipeTo( + _ops.StageActor, + success: bytesRead => new StreamBodyReadComplete(streamId, bytesRead), + failure: ex => new StreamBodyReadFailed(streamId, ex)); + } + + private void CleanupBodyDrain(long streamId) + { + if (_activeBodyBuffers.Remove(streamId, out var buffer)) + { + buffer.Dispose(); + } + + _activeBodyStreams.Remove(streamId); + } + private void EmitBatchedFrames(IReadOnlyList frames, long streamId) { if (frames.Count == 0) @@ -356,4 +484,4 @@ private void EmitSerializedFrame(Http3Frame frame, long streamId) EmitOutbound(new MultiplexedData(buf, streamId)); } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs index dcb776518..53dc185f5 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs @@ -2,14 +2,15 @@ using TurboHTTP.Client; using TurboHTTP.Internal; using TurboHTTP.Protocol.Multiplexed; -using TurboHTTP.Protocol.Syntax.Http3.Options; +using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http3.Client; internal sealed class Http3ClientStateMachine : IClientStateMachine { + private const string IdleTimeoutCheckTimer = "idle-timeout-check"; private static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromSeconds(30); private readonly TurboClientOptions _options; @@ -38,25 +39,8 @@ public Http3ClientStateMachine(TurboClientOptions options, IClientStageOperation _options = options; _ops = ops; - var shared = SharedHttpOptions.Default with - { - MaxBufferedBodySize = options.MaxBufferedBodySize, - MaxStreamedBodySize = options.MaxStreamedBodySize, - }; - - var encoderOpts = new Http3ClientEncoderOptions - { - QpackMaxTableCapacity = options.Http3.QpackMaxTableCapacity, - QpackBlockedStreams = options.Http3.QpackBlockedStreams, - Shared = shared, - }; - - var decoderOpts = new Http3ClientDecoderOptions - { - MaxConcurrentStreams = options.Http3.MaxConcurrentStreams, - MaxFieldSectionSize = options.Http3.MaxFieldSectionSize, - Shared = shared, - }; + var encoderOpts = options.ToHttp3EncoderOptions(); + var decoderOpts = options.ToHttp3DecoderOptions(); _clientSession = new Http3ClientSessionManager(encoderOpts, decoderOpts, options, ops); _reconnect = new ReconnectionManager(options.Http3.MaxReconnectAttempts, options.Http3.MaxReconnectBufferSize); @@ -82,7 +66,7 @@ public void OnRequest(HttpRequestMessage request) { if (Connection.GoAwayReceived) { - Tracing.For("Protocol").Warning(this, "RFC 9114 §5.2 — GOAWAY received; dropping outbound request."); + Tracing.For("Protocol").Warning(this, "RFC 9114 §5.2 - GOAWAY received; dropping outbound request."); return; } @@ -130,7 +114,7 @@ public void DecodeServerData(ITransportInbound data) return; } - case StreamOpened { Id: var openedId }: + case StreamOpened: { return; } @@ -141,7 +125,7 @@ public void DecodeServerData(ITransportInbound data) return; } - case StreamReadCompleted { Id: var srcId }: + case StreamReadCompleted: { return; } @@ -176,7 +160,7 @@ public void DecodeServerData(ITransportInbound data) case TransportData rawData: { Tracing.For("Protocol").Warning(this, - "Received untagged TransportData — dropping to prevent stream ID misrouting."); + "Received untagged TransportData - dropping to prevent stream ID misrouting."); rawData.Buffer.Dispose(); return; } @@ -190,7 +174,7 @@ public void OnUpstreamFinished() if (IsReconnecting) { Tracing.For("Protocol").Debug(this, - "HTTP/3 transport closed during reconnect — discarding in-flight request(s)."); + "HTTP/3 transport closed during reconnect - discarding in-flight request(s)."); var correlations = _clientSession.SnapshotAndClearCorrelations(); if (correlations.Count > 0) { @@ -202,7 +186,7 @@ public void OnUpstreamFinished() public void OnTimerFired(string name) { - if (name != "idle-timeout-check") + if (name != IdleTimeoutCheckTimer) { return; } @@ -221,6 +205,20 @@ public void OnTimerFired(string name) ScheduleIdleCheck(); } + public void OnRequestCancelled(HttpRequestMessage request) + { + if (IsReconnecting) + { + request.Fail(new OperationCanceledException("Request cancelled by caller.")); + return; + } + + if (_clientSession.TryCancelStream(request)) + { + Tracing.For("Protocol").Debug(this, "HTTP/3: cancelled request, sent STOP_SENDING"); + } + } + public void OnBodyMessage(object msg) { _clientSession.OnBodyMessage(msg); @@ -266,12 +264,13 @@ public void Cleanup() { if (!Connection.IsIdleTimeoutExpired() || Connection.ActiveStreamCount != 0) return null; Tracing.For("Protocol").Info(this, - "RFC 9114 §5.1 — idle timeout expired with no active streams; sending GOAWAY."); + "RFC 9114 §5.1 - idle timeout expired with no active streams; sending GOAWAY."); return new GoAwayFrame(0); } private void OnConnectionLost() { + Tracing.For("Protocol").Info(this, "HTTP/3: connection lost (inFlight={0})", HasInFlightRequests); var correlations = _clientSession.GetCorrelationMap().Values.ToList(); _reconnect.OnConnectionLost(correlations); @@ -287,6 +286,7 @@ private void OnConnectionLost() private void OnConnectionRestored() { + Tracing.For("Protocol").Info(this, "HTTP/3: connection restored"); var preface = _clientSession.TryBuildControlPreface(); if (preface is not null) { @@ -321,7 +321,7 @@ private void ScheduleIdleCheck() var remaining = Connection.TimeUntilExpiry(); var checkInterval = remaining > TimeSpan.Zero ? remaining : TimeSpan.FromSeconds(1); - _ops.OnScheduleTimer("idle-timeout-check", checkInterval); + _ops.OnScheduleTimer(IdleTimeoutCheckTimer, checkInterval); } private void BufferForReconnect(HttpRequestMessage request) @@ -338,14 +338,15 @@ private void HandleSettings(SettingsFrame settings) try { Connection.OnRemoteSettings(settings); - Tracing.For("Protocol").Info(this, "RFC 9114 §7.2.4 — remote SETTINGS received ({0} parameters).", + Tracing.For("Protocol").Info(this, "RFC 9114 §7.2.4 - remote SETTINGS received ({0} parameters).", settings.Parameters.Count); _clientSession.HandleSettings(settings); } catch (HttpProtocolException ex) { - Tracing.For("Protocol").Warning(this, "SETTINGS error absorbed — {0}", ex.Message); + // RFC 9114 §7.2.4: a malformed or repeated SETTINGS frame is a connection error (H3_SETTINGS_ERROR). + DisconnectOnConnectionError("control SETTINGS", ex); } } @@ -354,12 +355,12 @@ private void HandleGoAway(GoAwayFrame goAway) try { Connection.OnServerGoAway(goAway); - Tracing.For("Protocol").Info(this, "RFC 9114 §5.2 — GOAWAY received (streamId={0}).", goAway.StreamId); + Tracing.For("Protocol").Info(this, "RFC 9114 §5.2 - GOAWAY received (streamId={0}).", goAway.StreamId); } catch (HttpProtocolException ex) { - Tracing.For("Protocol").Warning(this, "GOAWAY error absorbed — {0}", ex.Message); - Connection.GoAwayReceived = true; + // RFC 9114 §5.2: a GOAWAY with an invalid or increasing stream ID is a connection error. + DisconnectOnConnectionError("control GOAWAY", ex); } } @@ -372,7 +373,7 @@ private void HandleGoAway(GoAwayFrame goAway) buf.Length = cancelFrame.SerializedSize; _ops.OnOutbound(new MultiplexedData(buf, CriticalStreamId.Control)); Tracing.For("Protocol").Info(this, - "RFC 9114 §7.2.5 — push promise rejected (pushId={0}); server push not supported", pushPromise.PushId); + "RFC 9114 §7.2.5 - push promise rejected (pushId={0}); server push not supported", pushPromise.PushId); return null; } @@ -396,10 +397,22 @@ private void HandleIncomingPushStream(long quicStreamId, ReadOnlySpan rema _ops.OnOutbound(new ResetStream(quicStreamId)); Tracing.For("Protocol").Info(this, - "RFC 9114 §4.6 — push stream {0} (pushId={1}) reset (push response delivery not implemented)", quicStreamId, + "RFC 9114 §4.6 - push stream {0} (pushId={1}) reset (push response delivery not implemented)", quicStreamId, pushId); } + /// + /// RFC 9114 §8 / RFC 9204 §2.2: a connection-fatal H3/QPACK error leaves the decoder or dynamic table + /// desynchronized. Disconnect the transport rather than swallowing and continuing; the resulting + /// TransportDisconnected routes through OnConnectionLost, which replays idempotent in-flight requests. + /// + private void DisconnectOnConnectionError(string context, Exception ex) + { + Tracing.For("Protocol").Info(this, + "HTTP/3: connection-fatal error ({0}) - disconnecting: {1}", context, ex.Message); + _ops.OnOutbound(new DisconnectTransport(DisconnectReason.Error)); + } + private void HandleTaggedStreamData(MultiplexedData multiplexed) { var resolved = _serverStreamResolver.Resolve(multiplexed.StreamId, multiplexed.Buffer); @@ -413,14 +426,36 @@ private void HandleTaggedStreamData(MultiplexedData multiplexed) { case CriticalStreamId.QpackDecoderId: { - _clientSession.ProcessQpackDecoderBytes(resolved.Buffer.Memory); - resolved.Buffer.Dispose(); + try + { + _clientSession.ProcessQpackDecoderBytes(resolved.Buffer.Memory); + } + catch (Exception ex) when (ex is QpackException or HuffmanException) + { + DisconnectOnConnectionError("QPACK decoder stream", ex); + } + finally + { + resolved.Buffer.Dispose(); + } + return; } case CriticalStreamId.QpackEncoderId: { - _clientSession.ProcessQpackEncoderBytes(resolved.Buffer.Memory); - resolved.Buffer.Dispose(); + try + { + _clientSession.ProcessQpackEncoderBytes(resolved.Buffer.Memory); + } + catch (Exception ex) when (ex is QpackException or HuffmanException) + { + DisconnectOnConnectionError("QPACK encoder stream", ex); + } + finally + { + resolved.Buffer.Dispose(); + } + return; } case CriticalStreamId.ControlId: @@ -438,16 +473,26 @@ private void HandleTaggedStreamData(MultiplexedData multiplexed) private void ProcessFrameData(TransportBuffer buffer, long streamId) { - var frames = _clientSession.DecodeServerData(buffer, streamId); - - for (var i = 0; i < frames.Count; i++) + try { - var frame = frames[i]; - var forwarded = ProcessFrame(frame); - if (forwarded is not null) + var frames = _clientSession.DecodeServerData(buffer, streamId); + + for (var i = 0; i < frames.Count; i++) { - _clientSession.AssembleResponse(forwarded, streamId); + var frame = frames[i]; + var forwarded = ProcessFrame(frame); + if (forwarded is not null) + { + _clientSession.AssembleResponse(forwarded, streamId); + } } } + catch (Exception ex) when (ex is HttpProtocolException or QpackException or HuffmanException) + { + // RFC 9114 §8: a framing or header-decode failure leaves the decoder/dynamic table + // desynchronized for the whole connection. Disconnect instead of letting it escape the + // decode loop (where the stage would swallow it and continue against corrupt state). + DisconnectOnConnectionError("frame decode", ex); + } } } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs index 9029257f1..2ab4a7e14 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs @@ -1,28 +1,28 @@ using System.Buffers; using Servus.Akka.Transport; using TurboHTTP.Internal; -using TurboHTTP.Protocol.Multiplexed.Body; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Protocol.Semantics; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http3.Client; /// -/// Manages per-stream response assembly, request–response correlation, and +/// Manages per-stream response assembly, request-response correlation, and /// frame-decoder / stream-state pooling for an HTTP/3 connection. /// Extracted from for single-responsibility. /// -internal sealed class StreamManager +internal sealed class StreamManager( + IClientStageOperations ops, + Http3ClientDecoder responseDecoder, + QpackTableSync tableSync, + long maxResponseBodySize) { private const int MaxPoolSize = 256; private const int MaxDecoderPoolSize = 256; - private readonly IClientStageOperations _ops; - private readonly Http3ClientDecoder _responseDecoder; - private readonly QpackTableSync _tableSync; - private readonly Dictionary _streams = new(); private readonly Dictionary _correlationMap = new(); private readonly Stack _statePool = new(); @@ -30,19 +30,9 @@ internal sealed class StreamManager private readonly Dictionary _streamDecoders = new(); private readonly Stack _decoderPool = new(); - /// Whether a response was produced during the most recent assembly call. - public bool ResponseProduced { get; private set; } - /// Whether there are in-flight requests awaiting responses. public bool HasInFlightRequests => _correlationMap.Count > 0 || _streams.Count > 0; - public StreamManager(IClientStageOperations ops, Http3ClientDecoder responseDecoder, QpackTableSync tableSync) - { - _ops = ops; - _responseDecoder = responseDecoder; - _tableSync = tableSync; - } - /// /// Decodes a TransportBuffer into HTTP/3 frames using a per-stream decoder. /// Each QUIC stream has independent framing, so decoders must not share @@ -64,10 +54,8 @@ public IReadOnlyList DecodeServerData(TransportBuffer buffer, long s /// /// Assembles a response from an HTTP/3 frame (HEADERS or DATA) on the given stream. /// - public void AssembleResponse(Http3Frame frame, long streamId, RequestEndpoint endpoint) + public void AssembleResponse(Http3Frame frame, long streamId) { - ResponseProduced = false; - if (!_streams.TryGetValue(streamId, out var state)) { state = RentStreamState(streamId); @@ -77,7 +65,7 @@ public void AssembleResponse(Http3Frame frame, long streamId, RequestEndpoint en switch (frame) { case HeadersFrame headers: - HandleResponseHeaders(headers, state, endpoint); + HandleResponseHeaders(headers, state); break; case DataFrame data: @@ -91,10 +79,10 @@ public void AssembleResponse(Http3Frame frame, long streamId, RequestEndpoint en /// public void FlushPendingResponse(long streamId) { - if (_streams.TryGetValue(streamId, out var state) && state.HasBodyDecoder) + if (_streams.TryGetValue(streamId, out var state) && state.HasBodyReader) { state.FeedBody(ReadOnlySpan.Empty, endStream: true); - state.DetachBodyDecoder(); + state.DetachBodyReader(); ReturnStreamState(streamId); return; } @@ -149,10 +137,10 @@ public void FlushAllPendingResponses() foreach (var (streamId, state) in _streams) { - if (state.HasBodyDecoder) + if (state.HasBodyReader) { state.FeedBody(ReadOnlySpan.Empty, endStream: true); - state.DetachBodyDecoder(); + state.DetachBodyReader(); handledStreamIds.Add(streamId); } } @@ -192,33 +180,31 @@ public void ResolveBlockedStreams( { if (!state.HasResponse) { - _responseDecoder.AssembleHeaders(headers, state); + responseDecoder.AssembleHeaders(headers, state); } - if (state.HasResponse && !state.HasBodyDecoder) + if (state is { HasResponse: true, HasBodyReader: false }) { - state.InitBodyDecoder(new StreamingBodyDecoder()); + var queued = new QueuedBodyReader(capacity: 8); + queued.Reset(); + state.InitBodyReader(queued, maxResponseBodySize); var response = state.GetResponse(); var bodyStream = state.GetBodyStream(); response.Content = new StreamContent(bodyStream); state.ApplyContentHeadersTo(response.Content); - // Correlate with original request if (_correlationMap.Remove(streamId, out var request)) { response.RequestMessage = request; } - ResponseProduced = true; - var partialContentResult = PartialContentValidator.Validate(response); if (!partialContentResult.IsValid) { Tracing.For("Protocol").Warning(this, "{0}", partialContentResult.ErrorMessage!); } - // Emit response immediately on resolved headers - _ops.OnResponse(response); + ops.OnResponse(response); } } } @@ -236,23 +222,19 @@ public StreamState GetOrCreateStreamState(long streamId) } /// - /// Registers a request correlation for the given stream ID. + /// Returns the stream state for the given stream ID, or null if not found. /// - public void Correlate(long streamId, HttpRequestMessage request) + public StreamState? TryGetStreamState(long streamId) { - _correlationMap[streamId] = request; + return _streams.GetValueOrDefault(streamId); } /// - /// Returns all correlated requests as a list and clears the correlation map. - /// Used during reconnection to snapshot old correlations for replay. + /// Registers a request correlation for the given stream ID. /// - public List SnapshotAndClearCorrelations() + public void Correlate(long streamId, HttpRequestMessage request) { - var result = new List(_correlationMap.Count); - result.AddRange(_correlationMap.Values); - _correlationMap.Clear(); - return result; + _correlationMap[streamId] = request; } /// @@ -316,57 +298,52 @@ public void Dispose() while (_statePool.TryPop(out _)) { - // Pool entries are already reset — just drain } } - private void HandleResponseHeaders(HeadersFrame frame, StreamState state, RequestEndpoint endpoint) + private void HandleResponseHeaders(HeadersFrame frame, StreamState state) { - var result = _tableSync.TryDecodeOrBlock(frame.HeaderBlock, (int)state.StreamId); + var result = tableSync.TryDecodeOrBlock(frame.HeaderBlock, (int)state.StreamId); if (result.IsBlocked) { return; } - if (!_responseDecoder.AssembleHeaders(result.Headers!, state)) + if (!responseDecoder.AssembleHeaders(result.Headers!, state)) { return; } var streamId = state.StreamId; - state.InitBodyDecoder(new StreamingBodyDecoder()); + var queued = new QueuedBodyReader(capacity: 8); + queued.Reset(); + state.InitBodyReader(queued, maxResponseBodySize); var response = state.GetResponse(); var bodyStream = state.GetBodyStream(); response.Content = new StreamContent(bodyStream); state.ApplyContentHeadersTo(response.Content); - // Correlate with original request if (_correlationMap.Remove(streamId, out var request)) { response.RequestMessage = request; } - ResponseProduced = true; - var partialContentResult = PartialContentValidator.Validate(response); if (!partialContentResult.IsValid) { Tracing.For("Protocol").Warning(this, "{0}", partialContentResult.ErrorMessage!); } - // Emit response immediately on headers - _ops.OnResponse(response); - - FlushDecoderInstructionsCallback?.Invoke(endpoint); + ops.OnResponse(response); } private void HandleResponseData(DataFrame frame, StreamState state) { - if (!state.HasBodyDecoder) + if (!state.HasBodyReader) { - Tracing.For("Protocol").Warning(this, "RFC 9114 §4.1 — DATA frame received before HEADERS; dropping."); + Tracing.For("Protocol").Warning(this, "RFC 9114 §4.1 - DATA frame received before HEADERS; dropping."); return; } @@ -393,15 +370,13 @@ private void EmitResponse(long streamId) response.RequestMessage = request; } - ResponseProduced = true; - var partialContentResult = PartialContentValidator.Validate(response); if (!partialContentResult.IsValid) { Tracing.For("Protocol").Warning(this, "{0}", partialContentResult.ErrorMessage!); } - _ops.OnResponse(response); + ops.OnResponse(response); ReturnStreamState(streamId); } @@ -460,15 +435,9 @@ private void ReturnDecoder(long streamId) } } - /// - /// Callback to flush QPACK decoder instructions after header decoding. - /// Set by to avoid circular dependency. - /// - internal Action? FlushDecoderInstructionsCallback { get; init; } - /// /// Callback invoked when a stream is closed (response emitted). /// The StateMachine uses this to update and . /// internal Action? OnStreamClosedCallback { get; init; } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/ConnectionState.cs b/src/TurboHTTP/Protocol/Syntax/Http3/ConnectionState.cs index fac0effcf..86cf39fe1 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/ConnectionState.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/ConnectionState.cs @@ -4,32 +4,22 @@ namespace TurboHTTP.Protocol.Syntax.Http3; /// Encapsulates all HTTP/3 connection-level state in a single class. /// Manages GoAway, Settings, idle timeout, and push state. /// -internal sealed class ConnectionState +internal sealed class ConnectionState(TimeSpan idleTimeout, int maxPushCount = 0) { - private readonly TimeSpan _idleTimeout; - public bool GoAwayReceived { get; set; } public long LastGoAwayStreamId { get; private set; } = -1; public bool RemoteSettingsReceived { get; private set; } public Settings? RemoteSettings { get; private set; } public long? RemoteMaxFieldSectionSize => RemoteSettings?.MaxFieldSectionSize; - private long _lastActivity; + private long _lastActivity = Environment.TickCount64; public int ActiveStreamCount { get; private set; } - public bool IsTimeoutDisabled => _idleTimeout == TimeSpan.Zero; + public bool IsTimeoutDisabled => idleTimeout == TimeSpan.Zero; public long MaxPushId { get; set; } private readonly HashSet _cancelledPushIds = []; private int _pushCount; - private readonly int _maxPushCount; - - public ConnectionState(TimeSpan idleTimeout, int maxPushCount = 0) - { - _idleTimeout = idleTimeout; - _maxPushCount = maxPushCount; - _lastActivity = Environment.TickCount64; - } public void OnServerGoAway(GoAwayFrame frame) { @@ -99,7 +89,7 @@ public bool IsIdleTimeoutExpired() return false; } - return Environment.TickCount64 - _lastActivity >= (long)_idleTimeout.TotalMilliseconds; + return Environment.TickCount64 - _lastActivity >= (long)idleTimeout.TotalMilliseconds; } public TimeSpan TimeUntilExpiry() @@ -109,7 +99,7 @@ public TimeSpan TimeUntilExpiry() return TimeSpan.MaxValue; } - var remainingMs = (long)_idleTimeout.TotalMilliseconds - (Environment.TickCount64 - _lastActivity); + var remainingMs = (long)idleTimeout.TotalMilliseconds - (Environment.TickCount64 - _lastActivity); return remainingMs > 0 ? TimeSpan.FromMilliseconds(remainingMs) : TimeSpan.Zero; } @@ -142,10 +132,10 @@ public static TimeSpan ComputeEffectiveTimeout(TimeSpan localTimeout, TimeSpan r public void RecordPush() { - if (_pushCount >= _maxPushCount) + if (_pushCount >= maxPushCount) { throw new HttpProtocolException( - $"Server exceeded push limit of {_maxPushCount} push promises (RFC 9114 §10.5)."); + $"Server exceeded push limit of {maxPushCount} push promises (RFC 9114 §10.5)."); } _pushCount++; diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/DecodeStatus.cs b/src/TurboHTTP/Protocol/Syntax/Http3/DecodeStatus.cs index 589ec969e..63e32d793 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/DecodeStatus.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/DecodeStatus.cs @@ -9,5 +9,5 @@ internal enum DecodeStatus Success, /// Not enough data to decode a complete frame; feed more bytes. - NeedMoreData, + NeedMoreData } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/ErrorCode.cs b/src/TurboHTTP/Protocol/Syntax/Http3/ErrorCode.cs index b35e0a3d2..2c7163652 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/ErrorCode.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/ErrorCode.cs @@ -22,6 +22,6 @@ internal enum ErrorCode : uint RequestIncomplete = 0x10d, MessageError = 0x10e, ConnectError = 0x10f, - VersionFallback = 0x110, + VersionFallback = 0x110 } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/FrameDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http3/FrameDecoder.cs index dd6607124..ddc4e2d20 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/FrameDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/FrameDecoder.cs @@ -85,7 +85,7 @@ public DecodeStatus TryDecode(ReadOnlySpan input, out Http3Frame? frame, o // All input bytes are accounted for: some went into the decoded frame // (together with the old remainder), the rest is buffered as the new remainder. // Returning input.Length prevents DecodeAll from re-passing bytes that are - // already captured in the remainder — avoiding double-counting corruption. + // already captured in the remainder - avoiding double-counting corruption. bytesConsumed = input.Length; // Buffer any leftover from combined @@ -203,7 +203,7 @@ private static DecodeStatus TryDecodeFrame( // Parse frame by type if (!Enum.IsDefined((FrameType)rawType)) { - // Unknown frame type — skip gracefully per RFC 9114 §7.2.8 + // Unknown frame type - skip gracefully per RFC 9114 §7.2.8 // Return a success with null frame to indicate skipped unknown frame frame = null; @@ -224,7 +224,7 @@ private static DecodeStatus TryDecodeFrame( FrameType.PushPromise => DecodePushPromiseFrame(payload), FrameType.GoAway => DecodeGoAwayFrame(payload), FrameType.MaxPushId => DecodeMaxPushIdFrame(payload), - _ => null, // Should not happen given IsDefined check above + _ => null // Should not happen given IsDefined check above }; return DecodeStatus.Success; @@ -305,5 +305,4 @@ private static MaxPushIdFrame DecodeMaxPushIdFrame(ReadOnlySpan payload) var pushId = QuicVarInt.Decode(payload, out _); return new MaxPushIdFrame(pushId); } -} - +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Http3Frame.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Http3Frame.cs index f535e7dfd..7452ec73f 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Http3Frame.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Http3Frame.cs @@ -2,7 +2,7 @@ namespace TurboHTTP.Protocol.Syntax.Http3; -// HTTP/3 Frame Types — RFC 9114 §7 +// HTTP/3 Frame Types - RFC 9114 §7 // // HTTP/3 Frame Format (RFC 9114 §7.1): // +-----------------------------------------------+ @@ -24,7 +24,7 @@ internal enum FrameType : long Settings = 0x04, PushPromise = 0x05, GoAway = 0x06, - MaxPushId = 0x0d, + MaxPushId = 0x0d } internal abstract class Http3Frame @@ -185,17 +185,12 @@ public override int WriteTo(ref Span span) /// SETTINGS frame (RFC 9114 §7.2.4). /// Conveys configuration parameters on the control stream. /// Each parameter is an identifier-value pair of QUIC variable-length integers. -/// Unlike HTTP/2, there is no ACK mechanism — the transport provides reliability. +/// Unlike HTTP/2, there is no ACK mechanism - the transport provides reliability. /// -internal sealed class SettingsFrame : Http3Frame +internal sealed class SettingsFrame(IReadOnlyList<(long Identifier, long Value)> parameters) : Http3Frame { public override FrameType Type => FrameType.Settings; - public IReadOnlyList<(long Identifier, long Value)> Parameters { get; } - - public SettingsFrame(IReadOnlyList<(long Identifier, long Value)> parameters) - { - Parameters = parameters; - } + public IReadOnlyList<(long Identifier, long Value)> Parameters { get; } = parameters; protected override int PayloadSize { @@ -361,25 +356,25 @@ internal static class SettingsIdentifier public const long QpackMaxTableCapacity = 0x01; /// - /// Reserved identifier — corresponds to HTTP/2 SETTINGS_ENABLE_PUSH. + /// Reserved identifier - corresponds to HTTP/2 SETTINGS_ENABLE_PUSH. /// MUST NOT be sent in HTTP/3 (RFC 9114 §7.2.4.1). /// public const long ReservedH2EnablePush = 0x02; /// - /// Reserved identifier — corresponds to HTTP/2 SETTINGS_MAX_CONCURRENT_STREAMS. + /// Reserved identifier - corresponds to HTTP/2 SETTINGS_MAX_CONCURRENT_STREAMS. /// MUST NOT be sent in HTTP/3 (RFC 9114 §7.2.4.1). /// public const long ReservedH2MaxConcurrentStreams = 0x03; /// - /// Reserved identifier — corresponds to HTTP/2 SETTINGS_INITIAL_WINDOW_SIZE. + /// Reserved identifier - corresponds to HTTP/2 SETTINGS_INITIAL_WINDOW_SIZE. /// MUST NOT be sent in HTTP/3 (RFC 9114 §7.2.4.1). /// public const long ReservedH2InitialWindowSize = 0x04; /// - /// Reserved identifier — corresponds to HTTP/2 SETTINGS_MAX_FRAME_SIZE. + /// Reserved identifier - corresponds to HTTP/2 SETTINGS_MAX_FRAME_SIZE. /// MUST NOT be sent in HTTP/3 (RFC 9114 §7.2.4.1). /// public const long ReservedH2MaxFrameSize = 0x05; diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptions.cs index 8f4dcf698..0ef8808f8 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientDecoderOptions.cs @@ -2,29 +2,6 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Options; internal sealed record Http3ClientDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int MaxConcurrentStreams { get; init; } = 100; - public int MaxFieldSectionSize { get; init; } = 64 * 1024; - - public static Http3ClientDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - - if (MaxConcurrentStreams <= 0) - { - throw new ArgumentException("MaxConcurrentStreams must be > 0.", nameof(MaxConcurrentStreams)); - } - - if (MaxFieldSectionSize <= 0) - { - throw new ArgumentException("MaxFieldSectionSize must be > 0.", nameof(MaxFieldSectionSize)); - } - } + public required int MaxConcurrentStreams { get; init; } + public required int MaxFieldSectionSize { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptions.cs index ce0b42ebe..13d655f00 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ClientEncoderOptions.cs @@ -2,29 +2,6 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Options; internal sealed record Http3ClientEncoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int QpackMaxTableCapacity { get; init; } = 16 * 1024; - public int QpackBlockedStreams { get; init; } = 100; - - public static Http3ClientEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - - if (QpackMaxTableCapacity < 0) - { - throw new ArgumentException("QpackMaxTableCapacity must be >= 0.", nameof(QpackMaxTableCapacity)); - } - - if (QpackBlockedStreams < 0) - { - throw new ArgumentException("QpackBlockedStreams must be >= 0.", nameof(QpackBlockedStreams)); - } - } + public required int QpackMaxTableCapacity { get; init; } + public required int QpackBlockedStreams { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerDecoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerDecoderOptions.cs index 8468b9018..19237fd08 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerDecoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerDecoderOptions.cs @@ -2,29 +2,8 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Options; internal sealed record Http3ServerDecoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public int MaxConcurrentStreams { get; init; } = 100; - public int MaxFieldSectionSize { get; init; } = 64 * 1024; - - public static Http3ServerDecoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - - if (MaxConcurrentStreams <= 0) - { - throw new ArgumentException("MaxConcurrentStreams must be > 0.", nameof(MaxConcurrentStreams)); - } - - if (MaxFieldSectionSize <= 0) - { - throw new ArgumentException("MaxFieldSectionSize must be > 0.", nameof(MaxFieldSectionSize)); - } - } + public required int MaxConcurrentStreams { get; init; } + public required int MaxFieldSectionSize { get; init; } + public required int MaxHeaderBytes { get; init; } + public required int MaxHeaderCount { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerEncoderOptions.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerEncoderOptions.cs index 15fb6f921..0170b472a 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerEncoderOptions.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Options/Http3ServerEncoderOptions.cs @@ -2,24 +2,9 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Options; internal sealed record Http3ServerEncoderOptions { - public SharedHttpOptions Shared { get; init; } = SharedHttpOptions.Default; - public bool WriteDateHeader { get; init; } = true; - public int QpackMaxTableCapacity { get; init; } = 16 * 1024; - - public static Http3ServerEncoderOptions Default { get; } = new(); - - public void Validate() - { - if (Shared is null) - { - throw new ArgumentException("Shared must not be null.", nameof(Shared)); - } - - Shared.Validate(); - - if (QpackMaxTableCapacity < 0) - { - throw new ArgumentException("QpackMaxTableCapacity must be >= 0.", nameof(QpackMaxTableCapacity)); - } - } + public required bool WriteDateHeader { get; init; } + public required int QpackMaxTableCapacity { get; init; } + public required int QpackBlockedStreams { get; init; } + public required int MaxHeaderBytes { get; init; } + public required bool UseHuffman { get; init; } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/OriginValidator.cs b/src/TurboHTTP/Protocol/Syntax/Http3/OriginValidator.cs index 1799ec2a0..ec067b910 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/OriginValidator.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/OriginValidator.cs @@ -1,7 +1,7 @@ namespace TurboHTTP.Protocol.Syntax.Http3; /// -/// RFC 9114 §10.3 — Validates request origins for intermediary encapsulation attack prevention. +/// RFC 9114 §10.3 - Validates request origins for intermediary encapsulation attack prevention. /// /// An intermediary that translates an HTTP/1.x request to HTTP/3 MUST reject requests /// targeting origins that contain features that cannot be safely represented in HTTP/3. diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/BlockedStream.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/BlockedStream.cs index b04800f5e..7d3db23c7 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/BlockedStream.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/BlockedStream.cs @@ -3,21 +3,14 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Qpack; /// /// Represents a blocked stream waiting for dynamic table updates. /// -internal sealed class BlockedStream +internal sealed class BlockedStream(int streamId, int requiredInsertCount, ReadOnlyMemory data) { /// The stream ID that is blocked. - public int StreamId { get; } + public int StreamId { get; } = streamId; /// The Required Insert Count that must be reached to unblock. - public int RequiredInsertCount { get; } + public int RequiredInsertCount { get; } = requiredInsertCount; /// The raw header block data to decode once unblocked. - public ReadOnlyMemory Data { get; } - - public BlockedStream(int streamId, int requiredInsertCount, ReadOnlyMemory data) - { - StreamId = streamId; - RequiredInsertCount = requiredInsertCount; - Data = data; - } + public ReadOnlyMemory Data { get; } = data; } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackDecoder.cs index 5493b251e..5ce3f3c00 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackDecoder.cs @@ -44,7 +44,7 @@ internal sealed class QpackDecoder /// Maximum number of streams that may be blocked waiting for dynamic table updates /// (SETTINGS_QPACK_BLOCKED_STREAMS). Default 0 means no blocking allowed. /// - public QpackDecoder(int maxTableCapacity = 4096, int maxBlockedStreams = 100) + public QpackDecoder(int maxTableCapacity, int maxBlockedStreams) { if (maxTableCapacity < 0) { @@ -172,9 +172,29 @@ public void ApplyEncoderInstruction(EncoderInstruction instruction) { case EncoderInstructionType.InsertWithNameReference: { - var name = instruction.IsStatic - ? QpackStaticTable.Entries[instruction.NameIndex].Name - : DynamicTable.GetEntry(DynamicTable.InsertCount - 1 - instruction.NameIndex)!.Value.Name; + string name; + if (instruction.IsStatic) + { + if (instruction.NameIndex < 0 || instruction.NameIndex >= QpackStaticTable.Entries.Length) + { + throw new QpackException( + $"RFC 9204 §3.2.4 violation: static name index {instruction.NameIndex} out of range."); + } + + name = QpackStaticTable.Entries[instruction.NameIndex].Name; + } + else + { + var entry = DynamicTable.GetEntry(DynamicTable.InsertCount - 1 - instruction.NameIndex); + if (entry is null) + { + throw new QpackException( + $"RFC 9204 §3.2.4 violation: dynamic name index {instruction.NameIndex} references a non-existent entry."); + } + + name = entry.Value.Name; + } + DynamicTable.Insert(name, instruction.Value); break; } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackEncoder.cs index 801aeb6ff..f6adbc374 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackEncoder.cs @@ -19,7 +19,7 @@ internal sealed class QpackEncoder private int _instructionBytesWritten; private readonly Dictionary _pendingSections = new(); - public QpackEncoder(int maxTableCapacity = 4096) + public QpackEncoder(int maxTableCapacity) { if (maxTableCapacity < 0) { @@ -483,7 +483,7 @@ private enum HeaderEncodingType LiteralWithDynamicName, LiteralWithStaticNameNeverIndex, LiteralNeverIndex, - Literal, + Literal } private struct HeaderEncodingEntry diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackStaticTable.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackStaticTable.cs index 243045c25..92e9a8d4a 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackStaticTable.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackStaticTable.cs @@ -16,8 +16,8 @@ internal static class QpackStaticTable /// public static readonly (string Name, string Value)[] Entries = [ - (":authority", string.Empty), // [0] - (":path", "/"), // [1] + (WellKnownHeaders.Authority, string.Empty), // [0] + (WellKnownHeaders.Path, "/"), // [1] ("age", "0"), // [2] ("content-disposition", string.Empty), // [3] ("content-length", "0"), // [4] @@ -31,20 +31,20 @@ public static readonly (string Name, string Value)[] Entries = ("location", string.Empty), // [12] ("referer", string.Empty), // [13] ("set-cookie", string.Empty), // [14] - (":method", "CONNECT"), // [15] - (":method", "DELETE"), // [16] - (":method", "GET"), // [17] - (":method", "HEAD"), // [18] - (":method", "OPTIONS"), // [19] - (":method", "POST"), // [20] - (":method", "PUT"), // [21] - (":scheme", "http"), // [22] - (":scheme", "https"), // [23] - (":status", "103"), // [24] - (":status", "200"), // [25] - (":status", "304"), // [26] - (":status", "404"), // [27] - (":status", "503"), // [28] + (WellKnownHeaders.Method, "CONNECT"), // [15] + (WellKnownHeaders.Method, "DELETE"), // [16] + (WellKnownHeaders.Method, "GET"), // [17] + (WellKnownHeaders.Method, "HEAD"), // [18] + (WellKnownHeaders.Method, "OPTIONS"), // [19] + (WellKnownHeaders.Method, "POST"), // [20] + (WellKnownHeaders.Method, "PUT"), // [21] + (WellKnownHeaders.Scheme, "http"), // [22] + (WellKnownHeaders.Scheme, "https"), // [23] + (WellKnownHeaders.Status, "103"), // [24] + (WellKnownHeaders.Status, "200"), // [25] + (WellKnownHeaders.Status, "304"), // [26] + (WellKnownHeaders.Status, "404"), // [27] + (WellKnownHeaders.Status, "503"), // [28] ("accept", "*/*"), // [29] ("accept", "application/dns-message"), // [30] ("accept-encoding", "gzip, deflate, br"), // [31] @@ -55,10 +55,10 @@ public static readonly (string Name, string Value)[] Entries = ("cache-control", "max-age=0"), // [36] ("cache-control", "max-age=2592000"), // [37] ("cache-control", "max-age=604800"), // [38] - ("cache-control", "no-cache"), // [39] + ("cache-control", WellKnownHeaders.NoCache), // [39] ("cache-control", "no-store"), // [40] ("cache-control", "public, max-age=31536000"), // [41] - ("content-encoding", "br"), // [42] + ("content-encoding", WellKnownHeaders.BrValue), // [42] ("content-encoding", "gzip"), // [43] ("content-type", "application/dns-message"), // [44] ("content-type", "application/javascript"), // [45] @@ -79,15 +79,15 @@ public static readonly (string Name, string Value)[] Entries = ("vary", "origin"), // [60] ("x-content-type-options", "nosniff"), // [61] ("x-xss-protection", "1; mode=block"), // [62] - (":status", "100"), // [63] - (":status", "204"), // [64] - (":status", "206"), // [65] - (":status", "302"), // [66] - (":status", "400"), // [67] - (":status", "403"), // [68] - (":status", "421"), // [69] - (":status", "425"), // [70] - (":status", "500"), // [71] + (WellKnownHeaders.Status, "100"), // [63] + (WellKnownHeaders.Status, "204"), // [64] + (WellKnownHeaders.Status, "206"), // [65] + (WellKnownHeaders.Status, "302"), // [66] + (WellKnownHeaders.Status, "400"), // [67] + (WellKnownHeaders.Status, "403"), // [68] + (WellKnownHeaders.Status, "421"), // [69] + (WellKnownHeaders.Status, "425"), // [70] + (WellKnownHeaders.Status, "500"), // [71] ("accept-language", string.Empty), // [72] ("access-control-allow-credentials", "FALSE"), // [73] ("access-control-allow-credentials", "TRUE"), // [74] @@ -114,7 +114,7 @@ public static readonly (string Name, string Value)[] Entries = ("user-agent", string.Empty), // [95] ("x-forwarded-for", string.Empty), // [96] ("x-frame-options", "deny"), // [97] - ("x-frame-options", "sameorigin"), // [98] + ("x-frame-options", "sameorigin") // [98] ]; /// diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackTableSync.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackTableSync.cs index dc54b17dc..bc56a44b4 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackTableSync.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Qpack/QpackTableSync.cs @@ -48,12 +48,12 @@ internal sealed class QpackTableSync /// (SETTINGS_QPACK_BLOCKED_STREAMS). /// /// - /// Our configured upper bound for the encoder's dynamic table (from Http3Options). + /// Our configured upper bound for the encoder's dynamic table (from Http3ClientOptions). /// Used by to cap the peer's advertised capacity. /// When null, defaults to . /// - public QpackTableSync(int encoderMaxCapacity = 0, int decoderMaxCapacity = 4096, - int maxBlockedStreams = 100, int? configuredEncoderLimit = null) + public QpackTableSync(int encoderMaxCapacity, int decoderMaxCapacity, + int maxBlockedStreams, int? configuredEncoderLimit) { if (maxBlockedStreams < 0) { diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/QpackStreamManager.cs b/src/TurboHTTP/Protocol/Syntax/Http3/QpackStreamManager.cs index 262edc7b2..79c581bfe 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/QpackStreamManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/QpackStreamManager.cs @@ -2,76 +2,45 @@ using Servus.Akka.Transport; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; namespace TurboHTTP.Protocol.Syntax.Http3; -internal sealed class QpackStreamManager +internal sealed class QpackStreamManager( + IClientStageOperations ops, + Client.Http3ClientEncoder requestEncoder, + Client.Http3ClientDecoder responseDecoder, + QpackTableSync tableSync) { - private readonly IClientStageOperations _ops; - private readonly Client.Http3ClientEncoder _requestEncoder; - private readonly Client.Http3ClientDecoder _responseDecoder; - private bool _encoderPrefaceSent; private bool _decoderPrefaceSent; - public QpackTableSync TableSync { get; } - - public QpackStreamManager( - IClientStageOperations ops, - Client.Http3ClientEncoder requestEncoder, - Client.Http3ClientDecoder responseDecoder, - QpackTableSync tableSync) - { - _ops = ops; - _requestEncoder = requestEncoder; - _responseDecoder = responseDecoder; - TableSync = tableSync; - } + public QpackTableSync TableSync { get; } = tableSync; - public void OpenCriticalStreams(Action emit) + public static void OpenCriticalStreams(Action emit) { emit(new OpenStream(CriticalStreamId.Control, StreamDirection.Unidirectional)); emit(new OpenStream(CriticalStreamId.QpackEncoder, StreamDirection.Unidirectional)); emit(new OpenStream(CriticalStreamId.QpackDecoder, StreamDirection.Unidirectional)); } + // RFC 9204 §2.2: a malformed QPACK encoder/decoder instruction is a connection error + // (QPACK_ENCODER_STREAM_ERROR / QPACK_DECODER_STREAM_ERROR). The dynamic table is desynchronized, + // so the connection cannot continue - let QpackException/HuffmanException propagate to the caller, + // which tears the connection down instead of decoding subsequent header blocks against a corrupt table. public void ProcessEncoderInstructions(ReadOnlySpan data) { - try - { - TableSync.ProcessEncoderInstructions(data); - } - catch (Exception ex) - { - Tracing.For("Protocol").Warning(this, "QPACK encoder stream error absorbed — {0}", ex.Message); - } + TableSync.ProcessEncoderInstructions(data); } public void ProcessDecoderInstructions(ReadOnlySpan data) { - try - { - TableSync.ProcessDecoderInstructions(data); - } - catch (Exception ex) - { - Tracing.For("Protocol").Warning(this, "QPACK decoder stream error absorbed — {0}", ex.Message); - } + TableSync.ProcessDecoderInstructions(data); } public IReadOnlyList<(int StreamId, IReadOnlyList<(string Name, string Value)> Headers)> ProcessEncoderInstructionsAndResolveBlocked(ReadOnlySpan data) { - try - { - TableSync.ProcessEncoderInstructions(data); - return TableSync.ResolveBlockedStreams(); - } - catch (Exception ex) - { - Tracing.For("Protocol").Warning(this, "QPACK encoder stream error absorbed — {0}", ex.Message); - return []; - } + TableSync.ProcessEncoderInstructions(data); + return TableSync.ResolveBlockedStreams(); } public void FlushPendingInstructions() @@ -82,7 +51,7 @@ public void FlushPendingInstructions() public void FlushEncoderInstructions() { - var instructions = _requestEncoder.EncoderInstructions; + var instructions = requestEncoder.EncoderInstructions; if (instructions.Length == 0) { return; @@ -109,12 +78,12 @@ public void FlushEncoderInstructions() owner.Memory.Span[..totalLength].CopyTo(buf.FullMemory.Span); buf.Length = totalLength; - _ops.OnOutbound(new MultiplexedData(buf, CriticalStreamId.QpackEncoder)); + ops.OnOutbound(new MultiplexedData(buf, CriticalStreamId.QpackEncoder)); } public void FlushDecoderInstructions() { - var sectionAck = _responseDecoder.DecoderInstructions; + var sectionAck = responseDecoder.DecoderInstructions; var buf = TransportBuffer.Rent(1 + sectionAck.Length + 16); var dest = buf.FullMemory.Span; @@ -143,7 +112,7 @@ public void FlushDecoderInstructions() _decoderPrefaceSent = true; buf.Length = offset; - _ops.OnOutbound(new MultiplexedData(buf, CriticalStreamId.QpackDecoder)); + ops.OnOutbound(new MultiplexedData(buf, CriticalStreamId.QpackDecoder)); } public void ApplyPeerSettings(Settings settings) diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Server/BodyRateState.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Server/BodyRateState.cs deleted file mode 100644 index 9c0541454..000000000 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Server/BodyRateState.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace TurboHTTP.Protocol.Syntax.Http3.Server; - -/// -/// Tracks request body data rate for a single stream. -/// Used to enforce minimum data rate with grace period, compatible with Kestrel's timeout model. -/// -internal sealed class BodyRateState -{ - /// - /// Total bytes received on this stream. - /// - public long TotalBytes { get; set; } - - /// - /// Bytes recorded at last check time (used to calculate rate). - /// - public long LastCheckBytes { get; set; } - - /// - /// Timestamp (in milliseconds from Environment.TickCount64) of last rate check. - /// - public long LastCheckTimestamp { get; set; } = Environment.TickCount64; - - /// - /// Timestamp (in milliseconds from Environment.TickCount64) when grace period started. - /// - public long GracePeriodStartTimestamp { get; set; } = Environment.TickCount64; - - /// - /// Whether the stream is currently in its grace period (allowed to have slow data rate). - /// - public bool InGracePeriod { get; set; } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerDecoder.cs index e153c98ec..76757b957 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerDecoder.cs @@ -1,5 +1,5 @@ -using Microsoft.AspNetCore.Http; using TurboHTTP.Protocol.Semantics; +using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Server.Context.Features; @@ -10,21 +10,21 @@ internal sealed class Http3ServerDecoder private const string PseudoHeaderSection = "RFC 9114 §4.3.1"; private const string UppercaseSection = "RFC 9114 §4.2"; private const string TokenSection = "RFC 9114 §10.3"; - private const string FieldValueSection = "RFC 9114 §10.3"; private const string ConnectionSection = "RFC 9114 §4.2"; private readonly QpackTableSync _tableSync; private readonly int _maxFieldSectionSize; + private readonly int _maxHeaderCount; - public Http3ServerDecoder(QpackTableSync tableSync, int maxFieldSectionSize = int.MaxValue) + public Http3ServerDecoder(QpackTableSync tableSync, Http3ServerDecoderOptions options) { ArgumentNullException.ThrowIfNull(tableSync); + ArgumentNullException.ThrowIfNull(options); _tableSync = tableSync; - _maxFieldSectionSize = maxFieldSectionSize; + _maxFieldSectionSize = options.MaxFieldSectionSize; + _maxHeaderCount = options.MaxHeaderCount; } - public ReadOnlyMemory DecoderInstructions => _tableSync.Decoder.DecoderInstructions; - public TurboHttpRequestFeature? DecodeHeadersToFeature(HeadersFrame frame, StreamState state, bool endStream) { ArgumentNullException.ThrowIfNull(frame); @@ -43,8 +43,7 @@ public Http3ServerDecoder(QpackTableSync tableSync, int maxFieldSectionSize = in var feature = new TurboHttpRequestFeature { - Protocol = "HTTP/3", - Headers = new HeaderDictionary() + Protocol = WellKnownHeaders.Http30 }; var isConnect = false; @@ -88,8 +87,8 @@ public Http3ServerDecoder(QpackTableSync tableSync, int maxFieldSectionSize = in if (!isConnect) { var path = state.GetPseudoHeader(WellKnownHeaders.Path); - var scheme = state.GetPseudoHeader(WellKnownHeaders.Scheme); - var authority = state.GetPseudoHeader(WellKnownHeaders.Authority); + _ = state.GetPseudoHeader(WellKnownHeaders.Scheme); + _ = state.GetPseudoHeader(WellKnownHeaders.Authority); feature.RawTarget = path; feature.QueryString = ParseQueryString(path); @@ -106,7 +105,29 @@ public Http3ServerDecoder(QpackTableSync tableSync, int maxFieldSectionSize = in return feature; } - internal static void ValidateRequestHeaders(IReadOnlyList<(string Name, string Value)> headers) + public void DecodeTrailers(HeadersFrame frame, StreamState state) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(state); + + var result = _tableSync.TryDecodeOrBlock(frame.HeaderBlock, (int)state.StreamId); + if (result.IsBlocked) + { + return; + } + + var headers = result.Headers!; + foreach (var (name, _) in headers) + { + if (name.StartsWith(WellKnownHeaders.Colon)) + { + throw new HttpProtocolException( + "RFC 9114 §4.3: Pseudo-headers are not allowed in trailers."); + } + } + } + + private static void ValidateRequestHeaders(IReadOnlyList<(string Name, string Value)> headers) { PseudoHeaderValidator.ValidateRequestPseudoHeaders( headers, @@ -120,12 +141,18 @@ internal static void ValidateRequestHeaders(IReadOnlyList<(string Name, string V static h => h.Value, UppercaseSection, TokenSection, - FieldValueSection, + TokenSection, ConnectionSection); } private void ValidateFieldSectionSize(IReadOnlyList<(string Name, string Value)> headers, long streamId) { + if (headers.Count > _maxHeaderCount) + { + throw new HttpProtocolException( + $"RFC 9114 §4.2.2: Header count {headers.Count} exceeds limit ({_maxHeaderCount}) on stream {streamId}."); + } + if (_maxFieldSectionSize == int.MaxValue) { return; diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs index d8007ee60..6622b6af0 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Protocol.Semantics; +using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; namespace TurboHTTP.Protocol.Syntax.Http3.Server; @@ -11,12 +13,15 @@ namespace TurboHTTP.Protocol.Syntax.Http3.Server; internal sealed class Http3ServerEncoder { private readonly QpackTableSync _tableSync; + private readonly Http3ServerEncoderOptions _options; private readonly List<(string Name, string Value)> _reusableHeaders = new(16); - public Http3ServerEncoder(QpackTableSync tableSync) + public Http3ServerEncoder(QpackTableSync tableSync, Http3ServerEncoderOptions options) { ArgumentNullException.ThrowIfNull(tableSync); + ArgumentNullException.ThrowIfNull(options); _tableSync = tableSync; + _options = options; } /// @@ -28,21 +33,21 @@ public Http3ServerEncoder(QpackTableSync tableSync) /// /// Encodes a response to HTTP/3 HEADERS frame only. - /// Body is handled asynchronously via IBodyEncoder and StreamState outbound buffer. + /// Body is handled asynchronously via PipeTo drain and StreamState outbound buffer. /// public HeadersFrame EncodeHeaders(IFeatureCollection features) { ArgumentNullException.ThrowIfNull(features); _reusableHeaders.Clear(); - BuildHeaderList(features, _reusableHeaders); + BuildHeaderList(features, _reusableHeaders, _options); var headerBlock = _tableSync.Encoder.Encode(_reusableHeaders); return new HeadersFrame(headerBlock); } - private static void BuildHeaderList(IFeatureCollection features, List<(string Name, string Value)> headers) + private static void BuildHeaderList(IFeatureCollection features, List<(string Name, string Value)> headers, Http3ServerEncoderOptions options) { // RFC 9114 §6.3: :status pseudo-header (required, must be first) var responseFeature = features.Get(); @@ -60,9 +65,14 @@ private static void BuildHeaderList(IFeatureCollection features, List<(string Na continue; } - var value = h.Value.Count == 1 ? h.Value[0]! : string.Join(", ", h.Value); + var value = ContentHeaderClassifier.JoinHeaderValues(h.Value); headers.Add((ContentHeaderClassifier.ToLowerAscii(h.Key), value)); } } + + if (options.WriteDateHeader && !headers.Any(h => h.Name.Equals(WellKnownHeaders.Date, StringComparison.OrdinalIgnoreCase))) + { + headers.Add(("date", DateHeaderCache.GetValue())); + } } } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs index d4fc1b4e0..d1b942e29 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs @@ -1,21 +1,32 @@ using System.Buffers; +using Akka.Actor; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; +using TurboHTTP.Protocol.Body; using TurboHTTP.Protocol.Multiplexed; -using TurboHTTP.Protocol.Multiplexed.Body; +using TurboHTTP.Protocol.Semantics; using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Protocol.Syntax.Http3.Qpack; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; using TurboHTTP.Streams.Stages.Server; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http3.Server; +internal readonly record struct StreamBodyReadComplete(long StreamId, int BytesRead); +internal readonly record struct StreamBodyReadFailed(long StreamId, Exception Reason); + internal sealed class Http3ServerSessionManager { private const int MaxStatePoolCapacity = 1000; + // RFC 9114 §8.1 / CVE-2023-44487 (Rapid Reset): client-initiated stream aborts are counted within + // this sliding window; exceeding the configured budget closes the connection (H3_EXCESSIVE_LOAD). + private const long ResetWindowMs = 30_000; + + private const string DataRateCheck = "data-rate-check"; + private readonly IServerStageOperations _ops; private readonly ServerStreamResolver _streamResolver = new(); private readonly Http3ServerDecoder _requestDecoder; @@ -24,38 +35,60 @@ internal sealed class Http3ServerSessionManager private readonly Http3ServerEncoderOptions _encoderOptions; private readonly Http3ServerDecoderOptions _decoderOptions; private readonly long _maxRequestBodySize; + private readonly int _responseBodyChunkSize; + private readonly TimeSpan _bodyConsumptionTimeout; private readonly Dictionary _streams = new(); + private readonly Dictionary _activeBodyStreams = new(); + private readonly Dictionary> _activeBodyBuffers = new(); private readonly StackStreamStatePool _statePool; - private readonly Dictionary _bodyRateStates = new(); + private readonly Stack _decoderPool = new(); + private const int MaxDecoderPoolSize = 256; + private readonly DataRateMonitor _requestRate; + private readonly DataRateMonitor _responseRate; + private readonly TimeProvider _clock; private bool _controlPrefaceSent; + private bool _settingsReceived; + + private readonly int _maxResetStreamsPerWindow; + private int _resetCount; + private long _resetWindowStart; + + private long Now() => _clock.GetUtcNow().ToUnixTimeMilliseconds(); public int ActiveStreamCount => _streams.Count; public int MaxConcurrentStreams => _decoderOptions.MaxConcurrentStreams; public Http3ServerSessionManager( - Http3ServerEncoderOptions encoderOptions, - Http3ServerDecoderOptions decoderOptions, + Http3ConnectionOptions options, IServerStageOperations ops, - long maxRequestBodySize = 30 * 1024 * 1024) + TimeProvider? timeProvider = null) { - _encoderOptions = encoderOptions; - _decoderOptions = decoderOptions; + _clock = timeProvider ?? TimeProvider.System; + _encoderOptions = options.ToEncoderOptions(); + _decoderOptions = options.ToDecoderOptions(); _ops = ops ?? throw new ArgumentNullException(nameof(ops)); - _maxRequestBodySize = maxRequestBodySize; + _maxRequestBodySize = options.Limits.MaxRequestBodySize; + _maxResetStreamsPerWindow = options.Limits.MaxResetStreamsPerWindow; + _responseBodyChunkSize = options.ToBodyEncoderOptions().ChunkSize; + _bodyConsumptionTimeout = options.BodyConsumptionTimeout; _tableSync = new QpackTableSync( encoderMaxCapacity: 0, - decoderMaxCapacity: encoderOptions.QpackMaxTableCapacity, - maxBlockedStreams: 100, - configuredEncoderLimit: encoderOptions.QpackMaxTableCapacity); + decoderMaxCapacity: _encoderOptions.QpackMaxTableCapacity, + maxBlockedStreams: _encoderOptions.QpackBlockedStreams, + configuredEncoderLimit: _encoderOptions.QpackMaxTableCapacity); + + _requestDecoder = new Http3ServerDecoder(_tableSync, _decoderOptions); + _responseEncoder = new Http3ServerEncoder(_tableSync, _encoderOptions); - _requestDecoder = new Http3ServerDecoder(_tableSync, int.MaxValue); - _responseEncoder = new Http3ServerEncoder(_tableSync); + var rate = options.ToRateMonitor(); + _requestRate = new DataRateMonitor(rate.MinRequestBodyDataRate, rate.MinRequestBodyDataRateGracePeriod); + _responseRate = new DataRateMonitor(rate.MinResponseDataRate, rate.MinResponseDataRateGracePeriod); var statePoolCapacity = Math.Min( - decoderOptions.MaxConcurrentStreams > 0 ? decoderOptions.MaxConcurrentStreams : 100, + _decoderOptions.MaxConcurrentStreams > 0 ? _decoderOptions.MaxConcurrentStreams : 100, MaxStatePoolCapacity); _statePool = new StackStreamStatePool( statePoolCapacity, @@ -72,41 +105,55 @@ public void PreStart() _ops.OnOutbound(preface); } + /// + /// True once a connection-fatal H3/QPACK error has occurred. The owning state machine surfaces + /// this so the connection stage closes the QUIC connection rather than continuing against a + /// desynchronized decoder. + /// + public bool ShouldComplete { get; private set; } + + public void SetComplete() => ShouldComplete = true; + public void DecodeClientData(ITransportInbound data) { switch (data) { case ServerStreamAccepted { Id: var id }: - { - _streamResolver.OnServerStreamOpened(id); - return; - } + { + _streamResolver.OnServerStreamOpened(id); + return; + } case MultiplexedData multiplexed: - { - HandleTaggedStreamData(multiplexed); - return; - } + { + HandleTaggedStreamData(multiplexed); + return; + } case StreamReadCompleted { Id.Value: >= 0 } readCompleted: - { - FlushPendingRequest(readCompleted.Id.Value); - return; - } + { + FlushPendingRequest(readCompleted.Id.Value); + return; + } case StreamClosed { Id.Value: >= 0 } streamClosed: + { + if (streamClosed.Reason == DisconnectReason.Error) { - FlushPendingRequest(streamClosed.Id.Value); - return; + TrackStreamReset(); } + FlushPendingRequest(streamClosed.Id.Value); + return; + } + case TransportData rawData: - { - Tracing.For("Protocol").Warning(this, - "Received untagged TransportData — dropping to prevent stream ID misrouting."); - rawData.Buffer.Dispose(); - return; - } + { + Tracing.For("Protocol").Warning(this, + "Received untagged TransportData - dropping to prevent stream ID misrouting."); + rawData.Buffer.Dispose(); + return; + } } } @@ -128,6 +175,11 @@ public void OnResponse(IFeatureCollection features) var (_, state) = streamData; + if (state.HasBodyReader && _bodyConsumptionTimeout > TimeSpan.Zero) + { + _ops.OnScheduleTimer(state.BodyConsumptionTimerKey, _bodyConsumptionTimeout); + } + var headersFrame = _responseEncoder.EncodeHeaders(features); EmitDataFrame(headersFrame, streamId); @@ -138,28 +190,28 @@ public void OnResponse(IFeatureCollection features) var hasBody = contentLength is not null and not 0 || (contentLength is null && hasStarted); - if (!hasBody) - { - _ops.OnOutbound(new CompleteWrites(streamId)); - return; - } - if (responseBody is not TurboHttpResponseBodyFeature turboBody) + if (!hasBody || responseBody is not TurboHttpResponseBodyFeature turboBody) { _ops.OnOutbound(new CompleteWrites(streamId)); return; } - var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength); - if (encoder is null) + if (turboBody.TryGetBufferedBody(out var bufferedBody)) { + if (bufferedBody.Length > 0) + { + EmitDataFrame(new DataFrame(bufferedBody), streamId); + } + _ops.OnOutbound(new CompleteWrites(streamId)); + CloseStream(streamId); return; } - state.InitBodyEncoder(encoder); - state.StartBodyEncoder(bodyStream, streamId, _ops.StageActor); - _ops.OnScheduleTimer(string.Concat("drain-body:", streamId.ToString()), TimeSpan.FromMilliseconds(0)); + var bodyStream = turboBody.GetResponseStream(); + state.MarkBodyDrainActive(); + StartStreamBodyDrain(streamId, bodyStream, contentLength); + Tracing.For("Protocol").Debug(this, "HTTP/3: response body drain started (stream={0})", streamId); } private static long? ExtractContentLength(IHttpResponseFeature? responseFeature) @@ -171,12 +223,10 @@ public void OnResponse(IFeatureCollection features) foreach (var header in responseFeature.Headers) { - if (header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + if (header.Key.Equals(WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase) && + header.Value.FirstOrDefault() is { } value && ContentLengthSemantics.TryParse(value, out var length)) { - if (header.Value.FirstOrDefault() is string value && long.TryParse(value, out var length)) - { - return length; - } + return length; } } @@ -187,50 +237,69 @@ public void OnBodyMessage(object msg) { switch (msg) { - case StreamBodyChunk chunk: - HandleOutboundBodyChunk(chunk); - break; - - case StreamBodyComplete complete: - HandleOutboundBodyComplete(complete.StreamId); + case StreamBodyReadComplete read: + HandleStreamBodyRead(read); break; - case StreamBodyFailed failed: + case StreamBodyReadFailed failed: Tracing.For("Protocol").Warning(this, - "HTTP/3: Response body encoding failed for stream {0}: {1}", failed.StreamId, + "HTTP/3: Response body drain failed for stream {0}: {1}", failed.StreamId, failed.Reason.Message); EmitRstStream(failed.StreamId, ErrorCode.GeneralProtocolError); + CleanupBodyDrain(failed.StreamId); break; } } - private void HandleOutboundBodyChunk(StreamBodyChunk chunk) + private void HandleStreamBodyRead(StreamBodyReadComplete read) { - if (!_streams.TryGetValue(chunk.StreamId, out var streamData)) + if (!_streams.TryGetValue(read.StreamId, out var streamData)) { - chunk.Owner.Dispose(); + CleanupBodyDrain(read.StreamId); return; } var (_, state) = streamData; - state.EnqueueBodyChunk(chunk); - DrainOutboundBuffer(chunk.StreamId); - } + state.IsBodyReadPending = false; - private void HandleOutboundBodyComplete(long streamId) - { - if (!_streams.TryGetValue(streamId, out var streamData)) + if (read.BytesRead == 0) + { + Tracing.For("Protocol").Debug(this, "HTTP/3: response body complete (stream={0})", read.StreamId); + state.MarkBodyDrainComplete(); + + if (!state.HasPendingOutbound) + { + _ops.OnOutbound(new CompleteWrites(read.StreamId)); + CleanupBodyDrain(read.StreamId); + CloseStream(read.StreamId); + } + else + { + CleanupBodyDrain(read.StreamId); + } + + return; + } + + Tracing.For("Protocol").Trace(this, "HTTP/3: response body chunk (stream={0}, bytes={1})", read.StreamId, read.BytesRead); + if (!_activeBodyBuffers.TryGetValue(read.StreamId, out var buffer)) { + CleanupBodyDrain(read.StreamId); return; } - var (_, state) = streamData; - state.MarkBodyEncoderComplete(); + var data = buffer.Memory[..read.BytesRead]; - if (!state.HasPendingOutbound) + var dataFrame = new DataFrame(data); + EmitDataFrame(dataFrame, read.StreamId); + + if (read.BytesRead > 0) { - _ops.OnOutbound(new CompleteWrites(streamId)); + _responseRate.Observe(read.StreamId, read.BytesRead, Now()); + EnsureRateTimer(); } + + ReadNextBodyChunk(read.StreamId); } public void DrainOutboundBuffer(long streamId) @@ -242,29 +311,23 @@ public void DrainOutboundBuffer(long streamId) var (_, state) = streamData; - const int maxFrameSize = 16384; - while (state.PeekBodyChunk() is { } chunk) { - var chunkSize = Math.Min(maxFrameSize, chunk.Length); - var dataFrame = new DataFrame(chunk.Owner.Memory[..chunkSize]); - + var dataFrame = new DataFrame(chunk.Owner.Memory[..chunk.Length]); EmitDataFrame(dataFrame, streamId); - if (chunkSize >= chunk.Length) - { - state.TryDequeueBodyChunk(out _); - chunk.Owner.Dispose(); - } - else - { - break; - } + state.TryDequeueBodyChunk(out _); + chunk.Owner.Dispose(); } - if (state is { HasPendingOutbound: false, IsBodyEncoderComplete: true }) + if (state is { HasPendingOutbound: false, IsBodyDrainComplete: true }) { _ops.OnOutbound(new CompleteWrites(streamId)); + CloseStream(streamId); + } + else if (!state.HasPendingOutbound && state.HasBodyDrain && !state.IsBodyDrainComplete && !state.IsBodyReadPending) + { + ReadNextBodyChunk(streamId); } } @@ -279,9 +342,14 @@ public void FlushAllPendingRequests() public void Cleanup() { + foreach (var streamId in _activeBodyStreams.Keys.ToList()) + { + CleanupBodyDrain(streamId); + } + foreach (var (_, (decoder, state)) in _streams) { - decoder.Dispose(); + ReturnDecoder(decoder); state.AbortBody(); state.Reset(); _statePool.Return(state); @@ -292,61 +360,31 @@ public void Cleanup() _tableSync.Reset(); } - public void CheckBodyRates(int minDataRate, TimeSpan gracePeriod) + public void CheckDataRates() { - var now = Environment.TickCount64; - var streamsToReset = new List(); - - foreach (var (streamId, state) in _bodyRateStates) - { - var elapsedMs = now - state.LastCheckTimestamp; - if (elapsedMs < 500) - { - continue; - } + var now = Now(); + var violations = new List(); - var elapsedSeconds = elapsedMs / 1000.0; - var bytesTransferred = state.TotalBytes - state.LastCheckBytes; - var rate = bytesTransferred / elapsedSeconds; + _requestRate.Check(now, violations); + _responseRate.Check(now, violations); - state.LastCheckBytes = state.TotalBytes; - state.LastCheckTimestamp = now; - - if (rate < minDataRate) - { - if (!state.InGracePeriod) - { - state.InGracePeriod = true; - state.GracePeriodStartTimestamp = now; - } - else - { - var graceElapsedMs = now - state.GracePeriodStartTimestamp; - if (graceElapsedMs > (long)gracePeriod.TotalMilliseconds) - { - streamsToReset.Add(streamId); - } - } - } - else - { - state.InGracePeriod = false; - } - } - - foreach (var streamId in streamsToReset) + var violationSet = new HashSet(violations); + foreach (var streamId in violationSet) { + Tracing.For("Protocol").Warning(this, "HTTP/3: data rate violation (stream={0})", streamId); EmitRstStream(streamId, ErrorCode.GeneralProtocolError); } - if (_bodyRateStates.Count > 0) + if (_requestRate.Count > 0 || _responseRate.Count > 0) { - _ops.OnScheduleTimer("body-rate-check", TimeSpan.FromSeconds(1)); + _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); } } + public void EmitRstStream(long streamId, ErrorCode errorCode) { + Tracing.For("Protocol").Debug(this, "HTTP/3: RST_STREAM (stream={0}, error={1})", streamId, errorCode); _ops.OnOutbound(new ResetStream(streamId, (long)errorCode)); CloseStream(streamId); } @@ -360,33 +398,27 @@ private void HandleTaggedStreamData(MultiplexedData multiplexed) return; } - if (logicalStreamId == CriticalStreamId.ControlId) - { - ProcessFrameData(transportBuffer, CriticalStreamId.ControlId); - return; - } - - if (logicalStreamId == CriticalStreamId.QpackEncoderId) + switch (logicalStreamId) { - transportBuffer.Dispose(); - return; - } - - if (logicalStreamId == CriticalStreamId.QpackDecoderId) - { - transportBuffer.Dispose(); - return; + case CriticalStreamId.ControlId: + ProcessFrameData(transportBuffer, CriticalStreamId.ControlId); + return; + case CriticalStreamId.QpackEncoderId: + case CriticalStreamId.QpackDecoderId: + transportBuffer.Dispose(); + return; + default: + ProcessFrameData(transportBuffer, logicalStreamId); + break; } - - ProcessFrameData(transportBuffer, logicalStreamId); } private void ProcessFrameData(TransportBuffer buffer, long streamId) { if (!_streams.TryGetValue(streamId, out var streamData)) { - var frameDecoder = new FrameDecoder(); - var streamState = new StreamState(); + var frameDecoder = RentDecoder(); + var streamState = _statePool.Rent(); streamState.Initialize(streamId); streamData = (frameDecoder, streamState); _streams[streamId] = streamData; @@ -394,7 +426,20 @@ private void ProcessFrameData(TransportBuffer buffer, long streamId) var (decoder, state) = streamData; - var frames = decoder.DecodeAll(buffer.Span, out _); + IReadOnlyList frames; + try + { + frames = decoder.DecodeAll(buffer.Span, out _); + } + catch (Exception ex) when (ex is HttpProtocolException or QpackException or HuffmanException) + { + buffer.Dispose(); + Tracing.For("Protocol").Warning(this, + "HTTP/3 connection framing error on stream {0} - closing connection: {1}", streamId, ex.Message); + ShouldComplete = true; + return; + } + buffer.Dispose(); foreach (var frame in frames) @@ -404,42 +449,111 @@ private void ProcessFrameData(TransportBuffer buffer, long streamId) switch (frame) { case HeadersFrame headersFrame: + { + if (state.GetRequestFeature() is not null) { - var requestFeature = _requestDecoder.DecodeHeadersToFeature(headersFrame, state, endStream: false); + _requestDecoder.DecodeTrailers(headersFrame, state); + state.FeedBody([], endStream: true); + } + else + { + var requestFeature = + _requestDecoder.DecodeHeadersToFeature(headersFrame, state, endStream: false); if (requestFeature is not null) { state.InitRequestFeature(requestFeature); } else { - _ops.OnScheduleTimer(string.Concat("headers-timeout:", streamId.ToString()), - TimeSpan.FromSeconds(30)); + _ops.OnScheduleTimer(state.HeadersTimeoutTimerKey, TimeSpan.FromSeconds(30)); } - - break; } + break; + } + case DataFrame dataFrame: - { - HandleDataFrame(dataFrame, streamId, state); - break; - } + { + HandleDataFrame(dataFrame, streamId, state); + break; + } + + case SettingsFrame settings: + { + HandleSettingsFrame(settings); + break; + } - case SettingsFrame: case GoAwayFrame: - { - break; - } + { + break; + } } } + catch (QpackException ex) + { + Tracing.For("Protocol").Warning(this, + "HTTP/3 QPACK error on stream {0} - closing connection: {1}", streamId, ex.Message); + ShouldComplete = true; + return; + } + catch (HuffmanException ex) + { + Tracing.For("Protocol").Warning(this, + "HTTP/3 Huffman error on stream {0} - closing connection: {1}", streamId, ex.Message); + ShouldComplete = true; + return; + } catch (HttpProtocolException ex) { Tracing.For("Protocol").Warning(this, - "HTTP/3 frame processing error on stream {0}: {1}", streamId, ex.Message); + "HTTP/3 message error on stream {0} - resetting stream: {1}", streamId, ex.Message); + EmitRstStream(streamId, ErrorCode.MessageError); } } } + private void HandleSettingsFrame(SettingsFrame settings) + { + if (_settingsReceived) + { + Tracing.For("Protocol").Warning(this, + "HTTP/3 RFC 9114 §7.2.4: duplicate SETTINGS frame on control stream - closing connection."); + ShouldComplete = true; + return; + } + + _settingsReceived = true; + } + + /// + /// RFC 9114 §8.1 / CVE-2023-44487: counts client-initiated stream aborts within a sliding window. A + /// client that opens-and-resets request streams faster than the configured budget is cut off + /// (H3_EXCESSIVE_LOAD) - MaxConcurrentStreams alone never saturates under this attack. + /// + private void TrackStreamReset() + { + if (_maxResetStreamsPerWindow <= 0) + { + return; + } + + var now = Now(); + if (now - _resetWindowStart >= ResetWindowMs) + { + _resetWindowStart = now; + _resetCount = 0; + } + + _resetCount++; + if (_resetCount > _maxResetStreamsPerWindow) + { + Tracing.For("Protocol").Warning(this, + "HTTP/3 RFC 9114 §8.1 / CVE-2023-44487: excessive stream resets - closing connection (ExcessiveLoad)."); + ShouldComplete = true; + } + } + private void FlushPendingRequest(long streamId) { if (!_streams.TryGetValue(streamId, out var streamData)) @@ -452,38 +566,35 @@ private void FlushPendingRequest(long streamId) var requestFeature = state.GetRequestFeature(); if (requestFeature is not null) { - _ops.OnCancelTimer(string.Concat("headers-timeout:", streamId.ToString())); + _ops.OnCancelTimer(state.HeadersTimeoutTimerKey); + _ops.OnCancelTimer(state.BodyConsumptionTimerKey); - var hasBody = state.HasBodyDecoder; + var hasBody = state.HasBodyReader; if (hasBody) { state.FeedBody(ReadOnlySpan.Empty, endStream: true); requestFeature.Body = state.GetBodyStream(); } - var features = FeatureCollectionFactory.Create(requestFeature, hasBody, _ops.Services, _ops.ConnectionFeature, _ops.TlsHandshakeFeature, _maxRequestBodySize); + var features = FeatureCollectionFactory.Create(requestFeature, hasBody, _ops.Services, + _ops.ConnectionFeature, _ops.TlsHandshakeFeature, _maxRequestBodySize); features.Set(new TurboStreamIdFeature(streamId)); var capturedStreamId = streamId; - features.Set(new TurboHttpResetFeature( - errorCode => EmitRstStream(capturedStreamId, (ErrorCode)errorCode))); + features.Set(new TurboHttpResetFeature(errorCode => + EmitRstStream(capturedStreamId, (ErrorCode)errorCode))); - _bodyRateStates.Remove(streamId); _ops.OnRequest(features); } } private void HandleDataFrame(DataFrame dataFrame, long streamId, StreamState state) { - if (!state.HasBodyDecoder) + if (!state.HasBodyReader) { - state.InitBodyDecoder(new StreamingBodyDecoder(_maxRequestBodySize)); - - if (!_bodyRateStates.ContainsKey(streamId)) - { - _bodyRateStates[streamId] = new BodyRateState(); - _ops.OnScheduleTimer("body-rate-check", TimeSpan.FromSeconds(1)); - } + var queued = new QueuedBodyReader(capacity: 8); + queued.Reset(); + state.InitBodyReader(queued, _maxRequestBodySize); } try @@ -499,7 +610,8 @@ private void HandleDataFrame(DataFrame dataFrame, long streamId, StreamState sta if (!dataFrame.Data.IsEmpty) { - _bodyRateStates[streamId].TotalBytes += dataFrame.Data.Length; + _requestRate.Observe(streamId, dataFrame.Data.Length, Now()); + EnsureRateTimer(); } } @@ -516,13 +628,17 @@ private long GetStreamIdFromFeatures(IFeatureCollection features) private void CloseStream(long streamId) { - _bodyRateStates.Remove(streamId); + _requestRate.Remove(streamId); + _responseRate.Remove(streamId); + CleanupBodyDrain(streamId); if (_streams.TryGetValue(streamId, out var streamData)) { var (decoder, state) = streamData; - decoder.Dispose(); + _ops.OnCancelTimer(state.BodyConsumptionTimerKey); + _ops.OnCancelTimer(state.HeadersTimeoutTimerKey); + ReturnDecoder(decoder); state.Reset(); _statePool.Return(state); @@ -530,6 +646,77 @@ private void CloseStream(long streamId) } } + private FrameDecoder RentDecoder() + { + if (_decoderPool.TryPop(out var decoder)) + { + decoder.Reset(); + return decoder; + } + + return new FrameDecoder(); + } + + private void ReturnDecoder(FrameDecoder decoder) + { + decoder.Reset(); + if (_decoderPool.Count < MaxDecoderPoolSize) + { + _decoderPool.Push(decoder); + } + else + { + decoder.Dispose(); + } + } + + private void StartStreamBodyDrain(long streamId, Stream bodyStream, long? contentLength = null) + { + _activeBodyStreams[streamId] = bodyStream; + var bufferSize = contentLength is > 0 and <= int.MaxValue + ? (int)Math.Min(contentLength.Value, _responseBodyChunkSize) + : _responseBodyChunkSize; + var buffer = MemoryPool.Shared.Rent(Math.Max(bufferSize, 256)); + _activeBodyBuffers[streamId] = buffer; + ReadNextBodyChunk(streamId); + } + + private void ReadNextBodyChunk(long streamId) + { + if (!_activeBodyStreams.TryGetValue(streamId, out var stream) || + !_activeBodyBuffers.TryGetValue(streamId, out var buffer)) + { + return; + } + + if (_streams.TryGetValue(streamId, out var streamData)) + { + streamData.State.IsBodyReadPending = true; + } + + var vt = stream.ReadAsync(buffer.Memory); + if (vt.IsCompletedSuccessfully) + { + HandleStreamBodyRead(new StreamBodyReadComplete(streamId, vt.Result)); + return; + } + + vt.AsTask().PipeTo( + _ops.StageActor, + success: bytesRead => new StreamBodyReadComplete(streamId, bytesRead), + failure: ex => new StreamBodyReadFailed(streamId, ex)); + } + + private void CleanupBodyDrain(long streamId) + { + if (_activeBodyBuffers.Remove(streamId, out var buffer)) + { + buffer.Dispose(); + } + + _activeBodyStreams.Remove(streamId); + } + private void EmitDataFrame(object frame, long streamId) { var serialized = frame switch @@ -567,7 +754,7 @@ private MultiplexedData BuildControlPreface() var settings = new Settings(); settings.Set(SettingsIdentifier.QpackMaxTableCapacity, _encoderOptions.QpackMaxTableCapacity); - settings.Set(SettingsIdentifier.QpackBlockedStreams, 100); + settings.Set(SettingsIdentifier.QpackBlockedStreams, _encoderOptions.QpackBlockedStreams); var settingsFrame = settings.ToFrame(); var streamTypeSize = QuicVarInt.EncodedLength((long)StreamType.Control); @@ -587,4 +774,6 @@ private MultiplexedData BuildControlPreface() return new MultiplexedData(buf, CriticalStreamId.Control); } -} \ No newline at end of file + + private void EnsureRateTimer() => _ops.OnScheduleTimer(DataRateCheck, TimeSpan.FromSeconds(1)); +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs index 1bd96affb..fd5ebf60f 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Protocol.Syntax.Http3.Options; using TurboHTTP.Server; using TurboHTTP.Streams.Stages.Server; +using static Servus.Senf; namespace TurboHTTP.Protocol.Syntax.Http3.Server; @@ -11,52 +11,27 @@ internal sealed class Http3ServerStateMachine : IServerStateMachine private const string DrainBodyPrefix = "drain-body:"; private const string HeadersTimeoutPrefix = "headers-timeout:"; private const string KeepAliveTimeout = "keep-alive-timeout"; - private const string BodyRateCheck = "body-rate-check"; + private const string DataRateCheck = "data-rate-check"; + private const string BodyConsumptionPrefix = "body-consumption:"; private readonly IServerStageOperations _ops; private readonly Http3ServerSessionManager _sessionManager; private readonly TimeSpan _keepAliveTimeout; - private readonly TimeSpan _requestHeadersTimeout; - private readonly int _minBodyDataRate; - private readonly TimeSpan _bodyRateGracePeriod; private int _activeStreamCount; public bool CanAcceptResponse => _sessionManager.ActiveStreamCount > 0; - public bool ShouldComplete => false; + public bool ShouldComplete => _sessionManager.ShouldComplete; public int MaxQueuedRequests => _sessionManager.MaxConcurrentStreams; - public Http3ServerStateMachine(TurboServerOptions options, IServerStageOperations ops) + public Http3ServerStateMachine(Http3ConnectionOptions options, IServerStageOperations ops) { _ops = ops ?? throw new ArgumentNullException(nameof(ops)); ArgumentNullException.ThrowIfNull(options); - var shared = SharedHttpOptions.Default with - { - MaxBufferedBodySize = options.BodyBufferThreshold, - MaxStreamedBodySize = options.Http3.MaxRequestBodySize, - MaxHeaderBytes = options.Http3.MaxHeaderListSize, - }; - - var encoderOpts = new Http3ServerEncoderOptions - { - Shared = shared, - QpackMaxTableCapacity = options.Http3.QpackMaxTableCapacity, - }; + _sessionManager = new Http3ServerSessionManager(options, ops); - var decoderOpts = new Http3ServerDecoderOptions - { - Shared = shared, - MaxConcurrentStreams = options.Http3.MaxConcurrentStreams, - MaxFieldSectionSize = options.Http3.MaxHeaderListSize, - }; - - _sessionManager = new Http3ServerSessionManager(encoderOpts, decoderOpts, ops, options.Http3.MaxRequestBodySize); - - _keepAliveTimeout = options.Http3.KeepAliveTimeout; - _requestHeadersTimeout = options.Http3.RequestHeadersTimeout; - _minBodyDataRate = options.Http3.MinRequestBodyDataRate; - _bodyRateGracePeriod = options.Http3.MinRequestBodyDataRateGracePeriod; + _keepAliveTimeout = options.Limits.KeepAliveTimeout; } public void PreStart() @@ -74,11 +49,13 @@ public void DecodeClientData(ITransportInbound data) { _activeStreamCount = streamCount; _ops.OnCancelTimer(KeepAliveTimeout); + Tracing.For("Protocol").Debug(this, "HTTP/3: first stream opened, keep-alive timer cancelled"); } else if (streamCount == 0 && _activeStreamCount > 0) { _activeStreamCount = 0; _ops.OnScheduleTimer(KeepAliveTimeout, _keepAliveTimeout); + Tracing.For("Protocol").Debug(this, "HTTP/3: all streams closed, keep-alive timer scheduled"); } else { @@ -100,12 +77,8 @@ public void OnTimerFired(string name) { if (name == KeepAliveTimeout) { - if (_activeStreamCount == 0) - { - return; - } - - _ops.OnScheduleTimer(KeepAliveTimeout, _keepAliveTimeout); + Tracing.For("Protocol").Info(this, "HTTP/3: keep-alive timeout - closing connection"); + _sessionManager.SetComplete(); return; } @@ -129,9 +102,16 @@ public void OnTimerFired(string name) return; } - if (name == BodyRateCheck) + if (name == DataRateCheck) + { + _sessionManager.CheckDataRates(); + return; + } + + if (name.StartsWith(BodyConsumptionPrefix) && + long.TryParse(name.AsSpan(BodyConsumptionPrefix.Length), out var consumptionStreamId)) { - _sessionManager.CheckBodyRates(_minBodyDataRate, _bodyRateGracePeriod); + _sessionManager.EmitRstStream(consumptionStreamId, ErrorCode.GeneralProtocolError); } } @@ -141,4 +121,4 @@ public void OnBodyMessage(object msg) } public void Cleanup() => _sessionManager.Cleanup(); -} +} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Settings.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Settings.cs index 561facd44..5df230d68 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Settings.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Settings.cs @@ -1,11 +1,11 @@ namespace TurboHTTP.Protocol.Syntax.Http3; -// HTTP/3 Settings — RFC 9114 §7.2.4 +// HTTP/3 Settings - RFC 9114 §7.2.4 // // SETTINGS parameters are conveyed in a SETTINGS frame on the control stream. // Each parameter is an identifier-value pair encoded as QUIC variable-length // integers. Unlike HTTP/2, identifiers use the same space but different -// semantics — HTTP/2 settings MUST NOT appear in HTTP/3 (§7.2.4.1). +// semantics - HTTP/2 settings MUST NOT appear in HTTP/3 (§7.2.4.1). // Unknown settings MUST be ignored (extension tolerance). /// diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/StreamState.cs b/src/TurboHTTP/Protocol/Syntax/Http3/StreamState.cs index 5667b6a98..9029a8ef4 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/StreamState.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/StreamState.cs @@ -1,5 +1,4 @@ -using Akka.Actor; -using TurboHTTP.Protocol.Multiplexed.Body; +using TurboHTTP.Protocol.Body; using TurboHTTP.Server.Context.Features; namespace TurboHTTP.Protocol.Syntax.Http3; @@ -7,7 +6,7 @@ namespace TurboHTTP.Protocol.Syntax.Http3; /// /// Unified per-stream state for HTTP/3 multiplexing (client and server). /// Manages response/request assembly, pseudo-headers, content headers, body buffering, -/// and body encoder/decoder handling. Pooled and reused via . +/// and body reader handling. Pooled and reused via . /// internal sealed class StreamState { @@ -15,9 +14,10 @@ internal sealed class StreamState private TurboHttpRequestFeature? _requestFeature; private List<(string Name, string Value)>? _contentHeaders; private Dictionary? _pseudoHeaders; - private IBodyDecoder? _bodyDecoder; - private IBodyEncoder? _bodyEncoder; - private Queue>? _outboundBuffer; + private IBodyReader? _bodyReader; + private long _maxBodySize; + private long _totalBodyBytes; + private Queue? _outboundBuffer; public long StreamId { get; private set; } = -1; @@ -25,19 +25,31 @@ internal sealed class StreamState public bool HasContentHeaders => _contentHeaders is not null; - public bool HasBodyDecoder => _bodyDecoder is not null; + public bool HasBodyReader => _bodyReader is not null; - public bool HasBodyEncoder => _bodyEncoder is not null; + public bool HasBodyDrain { get; private set; } public bool HasPendingOutbound => _outboundBuffer is { Count: > 0 }; - public bool IsBodyEncoderComplete { get; private set; } + public bool IsBodyDrainComplete { get; private set; } + + public bool IsBodyReadPending { get; set; } + + public long PendingOutboundBytes { get; private set; } public long? ExpectedContentLength { get; set; } + public string BodyConsumptionTimerKey { get; private set; } = ""; + public string HeadersTimeoutTimerKey { get; private set; } = ""; + public string DrainBodyTimerKey { get; private set; } = ""; + public void Initialize(long streamId) { StreamId = streamId; + var idStr = streamId.ToString(); + BodyConsumptionTimerKey = string.Concat("body-consumption:", idStr); + HeadersTimeoutTimerKey = string.Concat("headers-timeout:", idStr); + DrainBodyTimerKey = string.Concat("drain-body:", idStr); } public HttpResponseMessage InitResponse() @@ -98,81 +110,108 @@ public void ApplyContentHeadersTo(HttpContent content) } } - public void InitBodyDecoder(IBodyDecoder decoder) + public void InitBodyReader(IBodyReader reader, long maxBodySize = long.MaxValue) { - _bodyDecoder = decoder; + _bodyReader = reader; + _maxBodySize = maxBodySize; + _totalBodyBytes = 0; + } + + public void DetachBodyReader() + { + _bodyReader = null; } public void FeedBody(ReadOnlySpan data, bool endStream) { - if (HasBodyDecoder) + if (!data.IsEmpty) + { + _totalBodyBytes += data.Length; + if (_totalBodyBytes > _maxBodySize) + { + throw new HttpProtocolException( + string.Concat("Request body size ", _totalBodyBytes.ToString(), " exceeds limit ", _maxBodySize.ToString(), ".")); + } + } + + if (_bodyReader is IBufferedBodyReader buffered) { - _bodyDecoder?.Feed(data, endStream); + if (!data.IsEmpty) + { + buffered.Feed(data); + } + + if (endStream) + { + buffered.MarkComplete(); + } + + return; + } + + if (_bodyReader is IStreamingBodyReader streaming) + { + if (!data.IsEmpty) + { + streaming.TryEnqueue(data); + } + + if (endStream) + { + streaming.Complete(); + } } } public Stream GetBodyStream() { - if (_bodyDecoder is null) + if (_bodyReader is null) { - throw new InvalidOperationException("No body decoder has been initialized."); + throw new InvalidOperationException("No body reader has been initialized."); } - return _bodyDecoder.GetBodyStream(); + return _bodyReader.AsStream(); } public void AbortBody() { - _bodyDecoder?.Abort(); - } + if (_bodyReader is IStreamingBodyReader streaming) + { + streaming.Fault(new OperationCanceledException()); + } - public void DetachBodyDecoder() - { - _bodyDecoder = null; + _bodyReader?.Dispose(); } - public void InitBodyEncoder(IBodyEncoder encoder) + public void MarkBodyDrainActive() { - _bodyEncoder = encoder; + HasBodyDrain = true; + IsBodyDrainComplete = false; } - public void StartBodyEncoder(Stream bodyStream, long streamId, IActorRef stageActor) + public void MarkBodyDrainComplete() { - if (_bodyEncoder is null) - { - throw new InvalidOperationException("No body encoder has been initialized."); - } - - _bodyEncoder.Start(bodyStream, msg => - { - var tagged = msg switch - { - OutboundBodyChunk chunk => new StreamBodyChunk(streamId, chunk.Owner, chunk.Length), - OutboundBodyComplete => new StreamBodyComplete(streamId), - OutboundBodyFailed failed => new StreamBodyFailed(streamId, failed.Reason), - _ => msg - }; - - stageActor.Tell(tagged); - }); + IsBodyDrainComplete = true; } - public void EnqueueBodyChunk(StreamBodyChunk chunk) + public void EnqueueBodyChunk(StreamBodyChunk chunk) { - _outboundBuffer ??= new Queue>(); + _outboundBuffer ??= new Queue(); _outboundBuffer.Enqueue(chunk); + PendingOutboundBytes += chunk.Length; } - public StreamBodyChunk? PeekBodyChunk() + public StreamBodyChunk? PeekBodyChunk() { return _outboundBuffer is { Count: > 0 } ? _outboundBuffer.Peek() : null; } - public bool TryDequeueBodyChunk(out StreamBodyChunk? chunk) + public bool TryDequeueBodyChunk(out StreamBodyChunk? chunk) { if (_outboundBuffer is { Count: > 0 }) { chunk = _outboundBuffer.Dequeue(); + PendingOutboundBytes -= chunk.Length; return true; } @@ -180,11 +219,6 @@ public bool TryDequeueBodyChunk(out StreamBodyChunk? chunk) return false; } - public void MarkBodyEncoderComplete() - { - IsBodyEncoderComplete = true; - } - public void Reset() { StreamId = -1; @@ -193,13 +227,19 @@ public void Reset() ExpectedContentLength = null; _contentHeaders = null; _pseudoHeaders = null; - _bodyDecoder?.Dispose(); - _bodyDecoder = null; - _bodyEncoder?.Dispose(); - _bodyEncoder = null; + _bodyReader?.Dispose(); + _bodyReader = null; + _maxBodySize = 0; + _totalBodyBytes = 0; + HasBodyDrain = false; + IsBodyDrainComplete = false; + IsBodyReadPending = false; DisposeOutboundBuffer(); _outboundBuffer = null; - IsBodyEncoderComplete = false; + PendingOutboundBytes = 0; + BodyConsumptionTimerKey = ""; + HeadersTimeoutTimerKey = ""; + DrainBodyTimerKey = ""; } private void DisposeOutboundBuffer() @@ -213,5 +253,7 @@ private void DisposeOutboundBuffer() { _outboundBuffer.Dequeue().Owner.Dispose(); } + + PendingOutboundBytes = 0; } -} \ No newline at end of file +} diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/StreamTracker.cs b/src/TurboHTTP/Protocol/Syntax/Http3/StreamTracker.cs index 3e2a74414..03f048c4e 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/StreamTracker.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/StreamTracker.cs @@ -1,25 +1,19 @@ namespace TurboHTTP.Protocol.Syntax.Http3; /// -/// Tracks HTTP/3 stream lifecycle — ID allocation, active stream count, and concurrency limits. +/// Tracks HTTP/3 stream lifecycle - ID allocation, active stream count, and concurrency limits. /// RFC 9114 §6.1: Client-initiated bidirectional stream IDs are 0, 4, 8, 12, ... /// QUIC uses 62-bit variable-length integers, so stream IDs are . /// -internal sealed class StreamTracker +internal sealed class StreamTracker(long initialNextStreamId, int maxConcurrentStreams) { private readonly HashSet _activeStreamIds = []; - public StreamTracker(long initialNextStreamId = 0, int maxConcurrentStreams = 100) - { - NextStreamId = initialNextStreamId; - MaxConcurrentStreams = maxConcurrentStreams; - } - public int ActiveStreamCount { get; private set; } - public int MaxConcurrentStreams { get; set; } + public int MaxConcurrentStreams { get; set; } = maxConcurrentStreams; /// Current next stream ID (for testing/reset visibility). - public long NextStreamId { get; private set; } + public long NextStreamId { get; private set; } = initialNextStreamId; /// /// Returns true if a new stream can be opened without exceeding the concurrency limit. diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/StreamType.cs b/src/TurboHTTP/Protocol/Syntax/Http3/StreamType.cs index d522a3b3b..3e44a2784 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/StreamType.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/StreamType.cs @@ -29,6 +29,6 @@ internal enum StreamType : long /// QPACK decoder stream (RFC 9204 §4.2). Carries QPACK decoder /// instructions (acknowledgements, cancellations). /// - QpackDecoder = 0x03, + QpackDecoder = 0x03 } diff --git a/src/TurboHTTP/Protocol/Syntax/HttpMessageExtensions.cs b/src/TurboHTTP/Protocol/Syntax/HttpMessageExtensions.cs new file mode 100644 index 000000000..15a1fb53d --- /dev/null +++ b/src/TurboHTTP/Protocol/Syntax/HttpMessageExtensions.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Headers; +using TurboHTTP.Protocol.Semantics; + +namespace TurboHTTP.Protocol.Syntax; + +internal static class HttpMessageExtensions +{ + public static string ResolveTarget(this HttpRequestMessage request) + { + if (request.RequestUri is null) + { + return "/"; + } + + return request.RequestUri.IsAbsoluteUri ? request.RequestUri.PathAndQuery : request.RequestUri.OriginalString; + } + + public static HeaderCollection GetHeaderCollection(this HttpRequestMessage request) + { + var headerCollection = new HeaderCollection(); + request.Headers.GetHeaderCollection(ref headerCollection); + request.Content?.GetHeaderCollection(ref headerCollection); + return headerCollection; + } + + public static HeaderCollection GetHeaderCollection(this HttpResponseMessage response) + { + var headerCollection = new HeaderCollection(); + response.Headers.GetHeaderCollection(ref headerCollection); + return headerCollection; + } + + private static void GetHeaderCollection(this HttpHeaders headers, ref HeaderCollection collection) + { + foreach (var h in headers) + { + if (ConnectionSemantics.IsHopByHop(h.Key)) + { + continue; + } + + if (string.Equals(h.Key, WellKnownHeaders.Host, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (var v in h.Value) + { + var value = string.Equals(h.Key, "Referer", StringComparison.OrdinalIgnoreCase) + ? StripFragment(v) + : v; + collection.Add(h.Key, value); + } + } + } + + private static string StripFragment(string uri) + { + var idx = uri.IndexOf('#'); + return idx >= 0 ? uri[..idx] : uri; + } + + private static void GetHeaderCollection(this HttpContent content, ref HeaderCollection collection) + { + foreach (var h in content.Headers) + { + if (string.Equals(h.Key, WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (var v in h.Value) + { + collection.Add(h.Key, v); + } + } + } +} diff --git a/src/TurboHTTP/Protocol/Syntax/SharedHttpOptions.cs b/src/TurboHTTP/Protocol/Syntax/SharedHttpOptions.cs deleted file mode 100644 index 9373659fc..000000000 --- a/src/TurboHTTP/Protocol/Syntax/SharedHttpOptions.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Buffers; -using System.Net.Http.Headers; -using TurboHTTP.Protocol.Semantics; - -namespace TurboHTTP.Protocol.Syntax; - -internal sealed record SharedHttpOptions -{ - public long StreamingThreshold { get; init; } = 64 * 1024L; - public long MaxBufferedBodySize { get; init; } = 4 * 1024 * 1024L; - public long? MaxStreamedBodySize { get; init; } - public int MaxHeaderBytes { get; init; } = 32 * 1024; - public int MaxHeaderCount { get; init; } = 100; - public int HeaderLineMaxLength { get; init; } = 8 * 1024; - public int RequestLineMaxLength { get; init; } = 8 * 1024; - public bool AllowObsFold { get; init; } - public MemoryPool BufferPool { get; init; } = MemoryPool.Shared; - - public static SharedHttpOptions Default { get; } = new(); - - public void Validate() - { - if (StreamingThreshold < 0) - { - throw new ArgumentException( - $"SharedHttpOptions.StreamingThreshold must be >= 0 (got {StreamingThreshold})."); - } - - if (MaxBufferedBodySize < 0) - { - throw new ArgumentException( - $"SharedHttpOptions.MaxBufferedBodySize must be >= 0 (got {MaxBufferedBodySize})."); - } - - if (MaxBufferedBodySize < StreamingThreshold) - { - throw new ArgumentException( - $"SharedHttpOptions.MaxBufferedBodySize ({MaxBufferedBodySize}) must be >= StreamingThreshold ({StreamingThreshold})."); - } - - if (MaxStreamedBodySize is < 0) - { - throw new ArgumentException( - $"SharedHttpOptions.MaxStreamedBodySize must be null or >= 0 (got {MaxStreamedBodySize})."); - } - - if (MaxHeaderBytes <= 0) - { - throw new ArgumentException( - $"SharedHttpOptions.MaxHeaderBytes must be > 0 (got {MaxHeaderBytes})."); - } - - if (MaxHeaderCount <= 0) - { - throw new ArgumentException( - $"SharedHttpOptions.MaxHeaderCount must be > 0 (got {MaxHeaderCount})."); - } - - if (HeaderLineMaxLength <= 0) - { - throw new ArgumentException( - $"SharedHttpOptions.HeaderLineMaxLength must be > 0 (got {HeaderLineMaxLength})."); - } - - if (RequestLineMaxLength <= 0) - { - throw new ArgumentException( - $"SharedHttpOptions.RequestLineMaxLength must be > 0 (got {RequestLineMaxLength})."); - } - - if (BufferPool is null) - { - throw new ArgumentException("SharedHttpOptions.BufferPool must not be null."); - } - } -} - -internal static class Extensions -{ - public static string ResolveTarget(this HttpRequestMessage request) - { - if (request.RequestUri is null) - { - return "/"; - } - - return request.RequestUri.IsAbsoluteUri ? request.RequestUri.PathAndQuery : request.RequestUri.OriginalString; - } - - public static HeaderCollection GetHeaderCollection(this HttpRequestMessage request) - { - var headerCollection = new HeaderCollection(); - request.Headers.GetHeaderCollection(ref headerCollection); - request.Content?.GetHeaderCollection(ref headerCollection); - return headerCollection; - } - - public static HeaderCollection GetHeaderCollection(this HttpResponseMessage response) - { - var headerCollection = new HeaderCollection(); - response.Headers.GetHeaderCollection(ref headerCollection); - return headerCollection; - } - - private static void GetHeaderCollection(this HttpHeaders headers, ref HeaderCollection collection) - { - foreach (var h in headers) - { - if (ConnectionSemantics.IsHopByHop(h.Key)) - { - continue; - } - - if (string.Equals(h.Key, WellKnownHeaders.Host, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - foreach (var v in h.Value) - { - var value = string.Equals(h.Key, "Referer", StringComparison.OrdinalIgnoreCase) - ? StripFragment(v) - : v; - collection.Add(h.Key, value); - } - } - } - - private static string StripFragment(string uri) - { - var idx = uri.IndexOf('#'); - return idx >= 0 ? uri[..idx] : uri; - } - - private static void GetHeaderCollection(this HttpContent content, ref HeaderCollection collection) - { - foreach (var h in content.Headers) - { - if (string.Equals(h.Key, WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - foreach (var v in h.Value) - { - collection.Add(h.Key, v); - } - } - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/WellKnownHeaders.cs b/src/TurboHTTP/Protocol/WellKnownHeaders.cs index bef404f60..902d5af56 100644 --- a/src/TurboHTTP/Protocol/WellKnownHeaders.cs +++ b/src/TurboHTTP/Protocol/WellKnownHeaders.cs @@ -811,7 +811,7 @@ public static WellKnownHeader GetOrCreateHeaderNameIgnoreCase(ReadOnlySpan 25 => EqualsIgnoreCase(name, StrictTransportSecurity) ? StrictTransportSecurity : new WellKnownHeader(name), - _ => new WellKnownHeader(name), + _ => new WellKnownHeader(name) }; internal static bool EqualsIgnoreCase(ReadOnlySpan a, ReadOnlySpan b) diff --git a/src/TurboHTTP/Server/Context/Features/ITlsHandshakeFeature.cs b/src/TurboHTTP/Server/Context/Features/ITlsHandshakeFeature.cs index 64c972c49..d9a82955e 100644 --- a/src/TurboHTTP/Server/Context/Features/ITlsHandshakeFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/ITlsHandshakeFeature.cs @@ -3,10 +3,18 @@ namespace TurboHTTP.Server.Context.Features; +/// +/// Exposes TLS handshake details for a connection: the negotiated protocol version, +/// cipher suite, SNI host name, and ALPN application protocol. +/// public interface ITlsHandshakeFeature { + /// Gets the TLS protocol version negotiated during the handshake. SslProtocols Protocol { get; } + /// Gets the cipher suite negotiated during the handshake, or null if unavailable. TlsCipherSuite? NegotiatedCipherSuite { get; } + /// Gets the SNI host name provided by the client, or null if not supplied. string? HostName { get; } + /// Gets the ALPN application protocol negotiated during the handshake (e.g. "h2" or "h3"). SslApplicationProtocol NegotiatedApplicationProtocol { get; } } diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpBodyControlFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpBodyControlFeature.cs index 665ebaf06..0e7568e2d 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpBodyControlFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpBodyControlFeature.cs @@ -5,4 +5,9 @@ namespace TurboHTTP.Server.Context.Features; internal sealed class TurboHttpBodyControlFeature : IHttpBodyControlFeature { public bool AllowSynchronousIO { get; set; } + + internal void Reset() + { + AllowSynchronousIO = false; + } } diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpMaxRequestBodySizeFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpMaxRequestBodySizeFeature.cs index e4b766fa4..3427764a3 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpMaxRequestBodySizeFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpMaxRequestBodySizeFeature.cs @@ -6,4 +6,10 @@ internal sealed class TurboHttpMaxRequestBodySizeFeature : IHttpMaxRequestBodySi { public bool IsReadOnly { get; set; } public long? MaxRequestBodySize { get; set; } + + internal void Reset(long? maxSize) + { + IsReadOnly = false; + MaxRequestBodySize = maxSize; + } } diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestBodyDetectionFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestBodyDetectionFeature.cs index a33871db5..b13554c31 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestBodyDetectionFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestBodyDetectionFeature.cs @@ -2,8 +2,17 @@ namespace TurboHTTP.Server.Context.Features; -internal sealed class TurboHttpRequestBodyDetectionFeature(bool canHaveBody) - : IHttpRequestBodyDetectionFeature +internal sealed class TurboHttpRequestBodyDetectionFeature : IHttpRequestBodyDetectionFeature { - public bool CanHaveBody { get; } = canHaveBody; + public bool CanHaveBody { get; private set; } + + public TurboHttpRequestBodyDetectionFeature(bool canHaveBody) + { + CanHaveBody = canHaveBody; + } + + internal void Reset(bool canHaveBody) + { + CanHaveBody = canHaveBody; + } } \ No newline at end of file diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestFeature.cs index 0e56bcebd..cd267b770 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestFeature.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Protocol; namespace TurboHTTP.Server.Context.Features; @@ -7,11 +8,11 @@ internal sealed class TurboHttpRequestFeature : IHttpRequestFeature { private readonly TurboResponseHeaderDictionary _headers = new(); - public string Protocol { get; set; } = "HTTP/1.1"; + public string Protocol { get; set; } = WellKnownHeaders.Http11; public string Scheme { get; set; } = "http"; - public string Method { get; set; } = "GET"; + public string Method { get; set; } = WellKnownHeaders.Get; public string PathBase { get; set; } = string.Empty; @@ -28,16 +29,27 @@ public IHeaderDictionary Headers get => _headers; set { - if (value is not null) + _headers.Clear(); + foreach (var kvp in value) { - _headers.Clear(); - foreach (var kvp in value) - { - _headers[kvp.Key] = kvp.Value; - } + _headers[kvp.Key] = kvp.Value; } } } internal string? ExtractedHost { get; set; } + + internal void Reset() + { + Protocol = WellKnownHeaders.Http11; + Scheme = "http"; + Method = WellKnownHeaders.Get; + PathBase = string.Empty; + Path = "/"; + QueryString = string.Empty; + RawTarget = "/"; + Body = Stream.Null; + _headers.Clear(); + ExtractedHost = null; + } } diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestIdentifierFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestIdentifierFeature.cs index cefb079fd..c37deff11 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestIdentifierFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestIdentifierFeature.cs @@ -9,4 +9,9 @@ public string TraceIdentifier get => field ??= Guid.NewGuid().ToString("N"); set; } + + internal void Reset() + { + TraceIdentifier = null!; + } } diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestLifetimeFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestLifetimeFeature.cs index b4137930b..bd9060ea2 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpRequestLifetimeFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpRequestLifetimeFeature.cs @@ -4,7 +4,59 @@ namespace TurboHTTP.Server.Context.Features; internal sealed class TurboHttpRequestLifetimeFeature : IHttpRequestLifetimeFeature { - public CancellationToken RequestAborted { get; set; } + [ThreadStatic] private static Stack? _ctsPool; - public void Abort() => RequestAborted = new CancellationToken(true); + private const int MaxPoolSize = 64; + + private CancellationTokenSource _cts = RentCts(); + + public CancellationToken RequestAborted + { + get => _cts.Token; + set + { + if (value == _cts.Token) + { + return; + } + + var old = _cts; + _cts = CancellationTokenSource.CreateLinkedTokenSource(value); + ReturnCts(old); + } + } + + public void Abort() => _cts.Cancel(); + + internal void Reset() + { + var old = _cts; + _cts = RentCts(); + ReturnCts(old); + } + + private static CancellationTokenSource RentCts() + { + if (_ctsPool is { Count: > 0 }) + { + return _ctsPool.Pop(); + } + + return new CancellationTokenSource(); + } + + private static void ReturnCts(CancellationTokenSource cts) + { + if (cts.TryReset()) + { + _ctsPool ??= new Stack(MaxPoolSize); + if (_ctsPool.Count < MaxPoolSize) + { + _ctsPool.Push(cts); + return; + } + } + + cts.Dispose(); + } } diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpResetFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpResetFeature.cs index 19bfa85d3..03fcc3376 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpResetFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpResetFeature.cs @@ -2,14 +2,7 @@ namespace TurboHTTP.Server.Context.Features; -internal sealed class TurboHttpResetFeature : IHttpResetFeature +internal sealed class TurboHttpResetFeature(Action resetCallback) : IHttpResetFeature { - private readonly Action _resetCallback; - - public TurboHttpResetFeature(Action resetCallback) - { - _resetCallback = resetCallback; - } - - public void Reset(int errorCode) => _resetCallback(errorCode); + public void Reset(int errorCode) => resetCallback(errorCode); } diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs index 137affdf9..bd09f7378 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs @@ -9,34 +9,101 @@ namespace TurboHTTP.Server.Context.Features; internal sealed class TurboHttpResponseBodyFeature : IHttpResponseBodyFeature { - private readonly Pipe _pipe = new(); - private readonly ResponsePipeWriter _writer; + private Pipe? _pipe; + private ArrayBufferWriter _bufferWriter = FeatureCollectionFactory.RentBuffer(); + private ResponsePipeWriter _writer; + private Stream? _stream; + private Sink, Task>? _bodySink; public TurboHttpResponseBodyFeature() { - _writer = new ResponsePipeWriter(_pipe.Writer); + _writer = new ResponsePipeWriter(this); } - internal void SetOnStarting(Func onStarting) => _writer.SetOnStarting(onStarting); + internal void SetResponseFeature(TurboHttpResponseFeature feature) => _writer.SetResponseFeature(feature); internal bool HasStarted => _writer.HasStarted; internal Task WhenHeadersReady => _writer.WhenHeadersReady; - public Stream Stream => field ??= _writer.AsStream(leaveOpen: true); + public Stream Stream => _stream ??= _writer.AsStream(leaveOpen: true); public PipeWriter Writer => _writer; - public Task WhenSinkCompleted => Task.CompletedTask; + internal void Reset() + { + _stream = null; + _bodySink = null; + + if (_pipe is not null) + { + _pipe.Reader.Complete(); + _pipe.Writer.Complete(); + _pipe = null; + } + + _bufferWriter.ResetWrittenCount(); + _writer = new ResponsePipeWriter(this); + } + + internal bool TryGetBufferedBody(out ReadOnlyMemory body) + { + if (_pipe is null && _bufferWriter.WrittenCount > 0) + { + body = _bufferWriter.WrittenMemory; + return true; + } + + if (_pipe is not null && _writer.IsCompleted && _pipe.Reader.TryRead(out var result)) + { + if (result.IsCompleted && !result.Buffer.IsEmpty) + { + body = result.Buffer.ToArray(); + _pipe.Reader.AdvanceTo(result.Buffer.End); + return true; + } + + _pipe.Reader.AdvanceTo(result.Buffer.Start); + } + + body = default; + return false; + } + + internal void UpgradeToPipe() + { + if (_pipe is not null) + { + return; + } + + _pipe = new Pipe(); + + if (_bufferWriter.WrittenCount > 0) + { + var src = _bufferWriter.WrittenSpan; + var dest = _pipe.Writer.GetMemory(src.Length); + src.CopyTo(dest.Span); + _pipe.Writer.Advance(src.Length); + _pipe.Writer.FlushAsync(); + _bufferWriter.ResetWrittenCount(); + } + + if (_writer.IsCompleted) + { + _pipe.Writer.Complete(); + } + } public Sink, Task> BodySink { get { - if (field == null) + if (_bodySink == null) { - var pipeSink = PipeSink.To(_pipe.Writer); - field = Flow.Create>() + UpgradeToPipe(); + var pipeSink = PipeSink.To(_pipe!.Writer); + _bodySink = Flow.Create>() .SelectAsync(1, chunk => { _writer.CommitHeaders(); @@ -45,19 +112,20 @@ public Sink, Task> BodySink .ToMaterialized(pipeSink, Keep.Right); } - return field; + return _bodySink; } } - public Task StartAsync(CancellationToken cancellationToken = default) + public async Task StartAsync(CancellationToken cancellationToken = default) { - _writer.CommitHeaders(); - return Task.CompletedTask; + await _writer.CommitHeadersAsync(); } public async Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) { + UpgradeToPipe(); + await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4 * 1024, useAsync: true); if (offset > 0) @@ -96,136 +164,228 @@ internal void Complete() _writer.Complete(); } - public Task CompleteAsync() + public async Task CompleteAsync() { - return _writer.CompleteAsync().AsTask(); + await _writer.CompleteAsync(); } public void DisableBuffering() { + UpgradeToPipe(); } internal Source, NotUsed> GetResponseSource() { - return PipeSource.From(_pipe.Reader); + UpgradeToPipe(); + return PipeSource.From(_pipe!.Reader); } - internal Stream GetResponseStream() => _pipe.Reader.AsStream(); + internal PipeReader GetResponsePipeReader() + { + UpgradeToPipe(); + return _pipe!.Reader; + } - internal sealed class ResponsePipeWriter : PipeWriter + internal Stream GetResponseStream() { - private readonly PipeWriter _inner; + UpgradeToPipe(); + return _pipe!.Reader.AsStream(); + } + + private sealed class ResponsePipeWriter : PipeWriter + { + private readonly TurboHttpResponseBodyFeature _owner; private readonly TaskCompletionSource _headerCommit = new(TaskCreationOptions.RunContinuationsAsynchronously); - private Func? _onStarting; - private bool _started; - private bool _completed; - private long _bytesWritten; + private TurboHttpResponseFeature? _responseFeature; - public ResponsePipeWriter(PipeWriter inner) + public ResponsePipeWriter(TurboHttpResponseBodyFeature owner) { - _inner = inner; + _owner = owner; } public Task WhenHeadersReady => _headerCommit.Task; - public bool HasStarted => _started; - public long BytesWritten => _bytesWritten; + public bool HasStarted { get; private set; } + public bool IsCompleted { get; private set; } + public long BytesWritten { get; private set; } - public void SetOnStarting(Func onStarting) => _onStarting = onStarting; + public void SetResponseFeature(TurboHttpResponseFeature feature) => _responseFeature = feature; + + internal void Reset() + { + _responseFeature = null; + HasStarted = false; + IsCompleted = false; + BytesWritten = 0; + } public void CommitHeaders() { - if (!_started) + if (!HasStarted) { - _started = true; + HasStarted = true; + _owner.UpgradeToPipe(); _headerCommit.TrySetResult(); } } - public override bool CanGetUnflushedBytes => _inner.CanGetUnflushedBytes; - public override long UnflushedBytes => _inner.UnflushedBytes; - public override Memory GetMemory(int sizeHint = 0) => _inner.GetMemory(sizeHint); - public override Span GetSpan(int sizeHint = 0) => _inner.GetSpan(sizeHint); + public async Task CommitHeadersAsync() + { + if (!HasStarted) + { + HasStarted = true; + try + { + if (_responseFeature is not null) + { + await _responseFeature.FireOnStartingAsync(); + } + } + finally + { + _owner.UpgradeToPipe(); + _headerCommit.TrySetResult(); + } + } + } + + private PipeWriter? PipeWriterOrNull => _owner._pipe?.Writer; + + public override bool CanGetUnflushedBytes => true; + public override long UnflushedBytes => PipeWriterOrNull?.UnflushedBytes ?? _owner._bufferWriter.WrittenCount; + + public override Memory GetMemory(int sizeHint = 0) + { + if (_owner._pipe is not null) + { + return _owner._pipe.Writer.GetMemory(sizeHint); + } + + return _owner._bufferWriter.GetMemory(sizeHint); + } + + public override Span GetSpan(int sizeHint = 0) + { + if (_owner._pipe is not null) + { + return _owner._pipe.Writer.GetSpan(sizeHint); + } + + return _owner._bufferWriter.GetSpan(sizeHint); + } public override void Advance(int bytes) { - _inner.Advance(bytes); - _bytesWritten += bytes; + BytesWritten += bytes; + + if (_owner._pipe is not null) + { + _owner._pipe.Writer.Advance(bytes); + return; + } + + _owner._bufferWriter.Advance(bytes); } - public override void CancelPendingFlush() => _inner.CancelPendingFlush(); + public override void CancelPendingFlush() + { + _owner._pipe?.Writer.CancelPendingFlush(); + } public override ValueTask FlushAsync(CancellationToken cancellationToken = default) { - if (_started) + if (!HasStarted) { - return _inner.FlushAsync(cancellationToken); + return CommitAndFlushAsync(cancellationToken); } - return CommitAndFlushAsync(cancellationToken); + if (_owner._pipe is not null) + { + return _owner._pipe.Writer.FlushAsync(cancellationToken); + } + + return new ValueTask(new FlushResult(false, false)); } - public override ValueTask WriteAsync(ReadOnlyMemory source, CancellationToken cancellationToken = default) + public override ValueTask WriteAsync(ReadOnlyMemory source, + CancellationToken cancellationToken = default) { - if (_started) + if (!HasStarted) { - return _inner.WriteAsync(source, cancellationToken); + return CommitAndWriteAsync(source, cancellationToken); } - return CommitAndWriteAsync(source, cancellationToken); + if (_owner._pipe is not null) + { + return _owner._pipe.Writer.WriteAsync(source, cancellationToken); + } + + var dest = _owner._bufferWriter.GetSpan(source.Length); + source.Span.CopyTo(dest); + _owner._bufferWriter.Advance(source.Length); + BytesWritten += source.Length; + return new ValueTask(new FlushResult(false, false)); } private async ValueTask CommitAndFlushAsync(CancellationToken cancellationToken) { - _started = true; + HasStarted = true; try { - if (_onStarting is not null) + if (_responseFeature is not null) { - await _onStarting(); + await _responseFeature.FireOnStartingAsync(); } } finally { + _owner.UpgradeToPipe(); _headerCommit.TrySetResult(); } - return await _inner.FlushAsync(cancellationToken); + return await _owner._pipe!.Writer.FlushAsync(cancellationToken); } - private async ValueTask CommitAndWriteAsync(ReadOnlyMemory source, CancellationToken cancellationToken) + private async ValueTask CommitAndWriteAsync(ReadOnlyMemory source, + CancellationToken cancellationToken) { - _started = true; + HasStarted = true; try { - if (_onStarting is not null) + if (_responseFeature is not null) { - await _onStarting(); + await _responseFeature.FireOnStartingAsync(); } } finally { + _owner.UpgradeToPipe(); _headerCommit.TrySetResult(); } - _bytesWritten += source.Length; - return await _inner.WriteAsync(source, cancellationToken); + BytesWritten += source.Length; + return await _owner._pipe!.Writer.WriteAsync(source, cancellationToken); } public override void Complete(Exception? exception = null) { - if (!_completed) + if (!IsCompleted) { - _completed = true; - _inner.Complete(exception); + IsCompleted = true; + CommitHeaders(); + _owner._pipe?.Writer.Complete(exception); } } public override ValueTask CompleteAsync(Exception? exception = null) { - if (!_completed) + if (!IsCompleted) { - _completed = true; - return _inner.CompleteAsync(exception); + IsCompleted = true; + CommitHeaders(); + if (_owner._pipe is not null) + { + return _owner._pipe.Writer.CompleteAsync(exception); + } } return default; diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseFeature.cs index 473615e95..1255f7db0 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseFeature.cs @@ -6,8 +6,8 @@ namespace TurboHTTP.Server.Context.Features; internal sealed class TurboHttpResponseFeature : IHttpResponseFeature { private readonly TurboResponseHeaderDictionary _headers = new(); - private readonly List<(Func callback, object? state)> _onStartingCallbacks = []; - private readonly List<(Func callback, object? state)> _onCompletedCallbacks = []; + private List<(Func callback, object? state)>? _onStartingCallbacks; + private List<(Func callback, object? state)>? _onCompletedCallbacks; public int StatusCode { get; set; } = 200; @@ -26,13 +26,13 @@ public IHeaderDictionary Headers public void OnStarting(Func callback, object? state) { ArgumentNullException.ThrowIfNull(callback); - _onStartingCallbacks.Add((callback, state)); + (_onStartingCallbacks ??= []).Add((callback, state)); } public void OnCompleted(Func callback, object? state) { ArgumentNullException.ThrowIfNull(callback); - _onCompletedCallbacks.Add((callback, state)); + (_onCompletedCallbacks ??= []).Add((callback, state)); } void IHttpResponseFeature.OnStarting(Func callback, object state) @@ -50,6 +50,11 @@ void IHttpResponseFeature.OnCompleted(Func callback, object state) internal async Task FireOnStartingAsync() { HasStarted = true; + if (_onStartingCallbacks is null) + { + return; + } + foreach (var (callback, state) in _onStartingCallbacks) { await callback(state); @@ -58,6 +63,11 @@ internal async Task FireOnStartingAsync() internal async Task FireOnCompletedAsync() { + if (_onCompletedCallbacks is null) + { + return; + } + foreach (var (callback, state) in _onCompletedCallbacks) { await callback(state); @@ -70,8 +80,8 @@ internal void Reset() ReasonPhrase = null; HasStarted = false; Body = Stream.Null; - _onStartingCallbacks.Clear(); - _onCompletedCallbacks.Clear(); + _onStartingCallbacks?.Clear(); + _onCompletedCallbacks?.Clear(); _headers.Reset(); } } \ No newline at end of file diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseTrailersFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseTrailersFeature.cs index 0e95dffbe..f5eab86f9 100644 --- a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseTrailersFeature.cs +++ b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseTrailersFeature.cs @@ -6,7 +6,7 @@ namespace TurboHTTP.Server.Context.Features; internal sealed class TurboHttpResponseTrailersFeature : IHttpResponseTrailersFeature { - private TurboResponseHeaderDictionary _trailers = new(); + private readonly TurboResponseHeaderDictionary _trailers = new(); public IHeaderDictionary Trailers { @@ -15,18 +15,10 @@ public IHeaderDictionary Trailers } public IEnumerable> GetAllowedTrailers() - { - foreach (var header in _trailers) - { - if (TrailerFieldValidator.IsAllowedInTrailer(header.Key)) - { - yield return header; - } - } - } + => _trailers.Where(header => TrailerFieldValidator.IsAllowedInTrailer(header.Key)); internal void Reset() { _trailers.Clear(); } -} +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Context/ITurboFormCollection.cs b/src/TurboHTTP/Server/Context/ITurboFormCollection.cs deleted file mode 100644 index db5ae1286..000000000 --- a/src/TurboHTTP/Server/Context/ITurboFormCollection.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -public interface ITurboFormCollection : IEnumerable> -{ - StringValues this[string key] { get; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); - ITurboFormFileCollection Files { get; } -} - -public interface ITurboFormFileCollection : IEnumerable -{ - ITurboFormFile this[int index] { get; } - ITurboFormFile? this[string name] { get; } - int Count { get; } - ITurboFormFile? GetFile(string name); - IReadOnlyList GetFiles(string name); -} diff --git a/src/TurboHTTP/Server/Context/ITurboFormFile.cs b/src/TurboHTTP/Server/Context/ITurboFormFile.cs deleted file mode 100644 index d4948002c..000000000 --- a/src/TurboHTTP/Server/Context/ITurboFormFile.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace TurboHTTP.Server.Context; - -public interface ITurboFormFile -{ - string Name { get; } - string FileName { get; } - string ContentType { get; } - long Length { get; } - Stream OpenReadStream(); - void CopyTo(Stream target); - Task CopyToAsync(Stream target, CancellationToken cancellationToken = default); -} diff --git a/src/TurboHTTP/Server/Context/ITurboHeaderDictionary.cs b/src/TurboHTTP/Server/Context/ITurboHeaderDictionary.cs deleted file mode 100644 index a0bdb9632..000000000 --- a/src/TurboHTTP/Server/Context/ITurboHeaderDictionary.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -public interface ITurboHeaderDictionary : IEnumerable> -{ - StringValues this[string key] { get; set; } - long? ContentLength { get; set; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); - bool TryGetValue(string key, out StringValues value); - void Add(string key, StringValues value); - bool Remove(string key); - void Clear(); -} diff --git a/src/TurboHTTP/Server/Context/ITurboQueryCollection.cs b/src/TurboHTTP/Server/Context/ITurboQueryCollection.cs deleted file mode 100644 index 594e845f0..000000000 --- a/src/TurboHTTP/Server/Context/ITurboQueryCollection.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -public interface ITurboQueryCollection : IEnumerable> -{ - StringValues this[string key] { get; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); - bool TryGetValue(string key, out StringValues value); -} diff --git a/src/TurboHTTP/Server/Context/ITurboRequestCookieCollection.cs b/src/TurboHTTP/Server/Context/ITurboRequestCookieCollection.cs deleted file mode 100644 index 92e682ac8..000000000 --- a/src/TurboHTTP/Server/Context/ITurboRequestCookieCollection.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace TurboHTTP.Server.Context; - -public interface ITurboRequestCookieCollection : IEnumerable> -{ - string? this[string key] { get; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); -} diff --git a/src/TurboHTTP/Server/Context/TurboFormCollection.cs b/src/TurboHTTP/Server/Context/TurboFormCollection.cs deleted file mode 100644 index 9e36de12c..000000000 --- a/src/TurboHTTP/Server/Context/TurboFormCollection.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -internal sealed class TurboFormCollection(Dictionary fields, IFormFileCollection files) : IFormCollection, ITurboFormCollection -{ - public StringValues this[string key] - => fields.TryGetValue(key, out var value) ? value : StringValues.Empty; - - public int Count => fields.Count; - public ICollection Keys => fields.Keys; - public IFormFileCollection Files { get; } = files; - - public bool ContainsKey(string key) => fields.ContainsKey(key); - public bool TryGetValue(string key, out StringValues value) => fields.TryGetValue(key, out value); - public IEnumerator> GetEnumerator() => fields.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - ITurboFormFileCollection ITurboFormCollection.Files => (ITurboFormFileCollection)files; -} - -internal sealed class TurboFormFileCollection : IFormFileCollection, ITurboFormFileCollection -{ - private readonly List _files; - - public TurboFormFileCollection(List files) - { - _files = files; - } - - public IFormFile this[int index] => _files[index]; - - public IFormFile? this[string name] => _files.FirstOrDefault(f => - string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)); - - public int Count => _files.Count; - public IFormFile? GetFile(string name) => this[name]; - - public IReadOnlyList GetFiles(string name) - => _files.Where(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)).ToList(); - - public IEnumerator GetEnumerator() => _files.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - ITurboFormFile ITurboFormFileCollection.this[int index] => (ITurboFormFile)_files[index]; - - ITurboFormFile? ITurboFormFileCollection.this[string name] => (ITurboFormFile?)_files.FirstOrDefault(f => - string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)); - - ITurboFormFile? ITurboFormFileCollection.GetFile(string name) => (ITurboFormFile?)this[name]; - - IReadOnlyList ITurboFormFileCollection.GetFiles(string name) - => _files.Where(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)).ToList().Cast().ToList(); - - IEnumerator IEnumerable.GetEnumerator() - => _files.Cast().GetEnumerator(); -} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Context/TurboFormFile.cs b/src/TurboHTTP/Server/Context/TurboFormFile.cs deleted file mode 100644 index 1466a886b..000000000 --- a/src/TurboHTTP/Server/Context/TurboFormFile.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace TurboHTTP.Server.Context; - -internal sealed class TurboFormFile : IFormFile, ITurboFormFile -{ - private readonly byte[] _content; - - public TurboFormFile(string name, string fileName, string contentType, byte[] content) - { - Name = name; - FileName = fileName; - ContentType = contentType; - _content = content; - Length = content.Length; - Headers = new HeaderDictionary(); - } - - public string ContentDisposition => string.Concat("form-data; name=\"", Name, "\"; filename=\"", FileName, "\""); - public string ContentType { get; } - public string FileName { get; } - public IHeaderDictionary Headers { get; } - public long Length { get; } - public string Name { get; } - - public void CopyTo(Stream target) => target.Write(_content, 0, _content.Length); - public async Task CopyToAsync(Stream target, CancellationToken cancellationToken = default) - => await target.WriteAsync(_content, cancellationToken); - public Stream OpenReadStream() => new MemoryStream(_content, writable: false); -} diff --git a/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs b/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs index fc9779149..e58116aba 100644 --- a/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs +++ b/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs @@ -6,7 +6,13 @@ namespace TurboHTTP.Server.Context; -internal sealed class TurboResponseHeaderDictionary : IHeaderDictionary, ITurboHeaderDictionary +/// +/// Marker interface that extends to identify header dictionaries +/// managed by TurboHTTP (e.g. for type-safe retrieval from the feature collection). +/// +public interface ITurboHeaderDictionary : IHeaderDictionary; + +internal sealed class TurboResponseHeaderDictionary : ITurboHeaderDictionary { private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); @@ -125,4 +131,4 @@ internal void Reset() { _headers.Clear(); } -} +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/DataRateOptions.cs b/src/TurboHTTP/Server/DataRateOptions.cs new file mode 100644 index 000000000..25a0e5d26 --- /dev/null +++ b/src/TurboHTTP/Server/DataRateOptions.cs @@ -0,0 +1,7 @@ +namespace TurboHTTP.Server; + +internal readonly record struct DataRateOptions( + double MinRequestBodyDataRate, + TimeSpan MinRequestBodyDataRateGracePeriod, + double MinResponseDataRate, + TimeSpan MinResponseDataRateGracePeriod); diff --git a/src/TurboHTTP/Server/EndpointResolver.cs b/src/TurboHTTP/Server/EndpointResolver.cs index f952df740..7713aeeaa 100644 --- a/src/TurboHTTP/Server/EndpointResolver.cs +++ b/src/TurboHTTP/Server/EndpointResolver.cs @@ -195,6 +195,7 @@ private static ListenerBinding CreateTcpBinding(TurboListenOptions listen, X509C var alpn = protocols.ToAlpnProtocols(); var httpsOptions = listen.HttpsOptions; + var transport = listen.Transport?.ResolveTcp() ?? TransportBufferOptions.TcpDefaults; var tcpOptions = new TcpListenerOptions { Host = listen.Address.ToString(), @@ -205,7 +206,12 @@ private static ListenerBinding CreateTcpBinding(TurboListenOptions listen, X509C ClientCertificateValidationCallback = httpsOptions?.ClientCertificateValidationCallback, HandshakeTimeout = httpsOptions?.HandshakeTimeout ?? TimeSpan.FromSeconds(10), ClientCertificateMode = httpsOptions?.ClientCertificateMode ?? ClientCertificateMode.NoCertificate, - ServerCertificateSelector = httpsOptions?.ServerCertificateSelector + ServerCertificateSelector = httpsOptions?.ServerCertificateSelector, + InputPauseThreshold = transport.InputPauseThreshold, + InputResumeThreshold = transport.InputResumeThreshold, + OutputPauseThreshold = transport.OutputPauseThreshold, + OutputResumeThreshold = transport.OutputResumeThreshold, + MinimumSegmentSize = transport.MinimumSegmentSize }; return new ListenerBinding @@ -218,6 +224,7 @@ private static ListenerBinding CreateTcpBinding(TurboListenOptions listen, X509C private static ListenerBinding CreateQuicBinding(TurboListenOptions listen, X509Certificate2 certificate) { + var transport = listen.Transport?.ResolveQuic() ?? TransportBufferOptions.QuicDefaults; var quicOptions = new QuicListenerOptions { Host = listen.Address.ToString(), @@ -226,7 +233,12 @@ private static ListenerBinding CreateQuicBinding(TurboListenOptions listen, X509 ApplicationProtocols = [SslApplicationProtocol.Http3], EnabledSslProtocols = listen.HttpsOptions?.EnabledSslProtocols ?? SslProtocols.None, - ClientCertificateValidationCallback = listen.HttpsOptions?.ClientCertificateValidationCallback + ClientCertificateValidationCallback = listen.HttpsOptions?.ClientCertificateValidationCallback, + InputPauseThreshold = transport.InputPauseThreshold, + InputResumeThreshold = transport.InputResumeThreshold, + OutputPauseThreshold = transport.OutputPauseThreshold, + OutputResumeThreshold = transport.OutputResumeThreshold, + MinimumSegmentSize = transport.MinimumSegmentSize }; return new ListenerBinding diff --git a/src/TurboHTTP/Server/FeatureCollectionFactory.cs b/src/TurboHTTP/Server/FeatureCollectionFactory.cs index 039f89509..54a95e1a8 100644 --- a/src/TurboHTTP/Server/FeatureCollectionFactory.cs +++ b/src/TurboHTTP/Server/FeatureCollectionFactory.cs @@ -1,3 +1,4 @@ +using System.Buffers; using Microsoft.AspNetCore.Http.Features; using TurboHTTP.Server.Context.Features; @@ -6,6 +7,7 @@ namespace TurboHTTP.Server; internal static class FeatureCollectionFactory { [ThreadStatic] private static Stack? _tPool; + [ThreadStatic] private static Stack>? _bufferPool; private const int MaxPoolSize = 32; @@ -18,20 +20,53 @@ public static IFeatureCollection Create( long? maxRequestBodySize = null) { var features = (_tPool?.Count ?? 0) > 0 ? _tPool!.Pop() : new TurboFeatureCollection(); + var recycled = features.Get() is not null; features.Set(requestFeature); - var responseFeature = new TurboHttpResponseFeature(); - features.Set(responseFeature); + TurboHttpResponseFeature responseFeature; + if (recycled && features.Get() is TurboHttpResponseFeature existingResponse) + { + existingResponse.Reset(); + responseFeature = existingResponse; + } + else + { + responseFeature = new TurboHttpResponseFeature(); + features.Set(responseFeature); + } - var detectionFeature = new TurboHttpRequestBodyDetectionFeature(hasBody); - features.Set(detectionFeature); + if (recycled && features.Get() is TurboHttpRequestBodyDetectionFeature existingDetection) + { + existingDetection.Reset(hasBody); + } + else + { + features.Set(new TurboHttpRequestBodyDetectionFeature(hasBody)); + } - var responseBodyFeature = new TurboHttpResponseBodyFeature(); - features.Set(responseBodyFeature); + TurboHttpResponseBodyFeature responseBodyFeature; + if (recycled && features.Get() is TurboHttpResponseBodyFeature existingBody) + { + existingBody.Reset(); + responseBodyFeature = existingBody; + } + else + { + responseBodyFeature = new TurboHttpResponseBodyFeature(); + features.Set(responseBodyFeature); + } - var trailersFeature = new TurboHttpResponseTrailersFeature(); - features.Set(trailersFeature); + responseBodyFeature.SetResponseFeature(responseFeature); + + if (recycled && features.Get() is TurboHttpResponseTrailersFeature existingTrailers) + { + existingTrailers.Reset(); + } + else + { + features.Set(new TurboHttpResponseTrailersFeature()); + } if (connectionFeature is not null) { @@ -43,20 +78,41 @@ public static IFeatureCollection Create( features.Set(tlsFeature); } - var lifetimeFeature = new TurboHttpRequestLifetimeFeature(); - features.Set(lifetimeFeature); + if (recycled && features.Get() is TurboHttpRequestLifetimeFeature existingLifetime) + { + existingLifetime.Reset(); + } + else + { + features.Set(new TurboHttpRequestLifetimeFeature()); + } - var identifierFeature = new TurboHttpRequestIdentifierFeature(); - features.Set(identifierFeature); + if (recycled && features.Get() is TurboHttpRequestIdentifierFeature existingIdentifier) + { + existingIdentifier.Reset(); + } + else + { + features.Set(new TurboHttpRequestIdentifierFeature()); + } - var maxBodyFeature = new TurboHttpMaxRequestBodySizeFeature + if (recycled && features.Get() is TurboHttpMaxRequestBodySizeFeature existingMaxBody) + { + existingMaxBody.Reset(maxRequestBodySize); + } + else { - MaxRequestBodySize = maxRequestBodySize - }; - features.Set(maxBodyFeature); + features.Set(new TurboHttpMaxRequestBodySizeFeature { MaxRequestBodySize = maxRequestBodySize }); + } - var bodyControlFeature = new TurboHttpBodyControlFeature(); - features.Set(bodyControlFeature); + if (recycled && features.Get() is TurboHttpBodyControlFeature existingBodyControl) + { + existingBodyControl.Reset(); + } + else + { + features.Set(new TurboHttpBodyControlFeature()); + } return features; } @@ -71,6 +127,11 @@ internal static void Return(IFeatureCollection features) turboFeatures.RequestTimestamp = 0; turboFeatures.RequestActivity = null; + if (features.Get() is TurboHttpRequestLifetimeFeature lifetime) + { + lifetime.Reset(); + } + _tPool ??= new Stack(MaxPoolSize); if (_tPool.Count < MaxPoolSize) @@ -78,4 +139,26 @@ internal static void Return(IFeatureCollection features) _tPool.Push(turboFeatures); } } -} \ No newline at end of file + + internal static ArrayBufferWriter RentBuffer() + { + if ((_bufferPool?.Count ?? 0) > 0) + { + var buf = _bufferPool!.Pop(); + buf.ResetWrittenCount(); + return buf; + } + + return new ArrayBufferWriter(); + } + + internal static void ReturnBuffer(ArrayBufferWriter buffer) + { + buffer.ResetWrittenCount(); + _bufferPool ??= new Stack>(MaxPoolSize); + if (_bufferPool.Count < MaxPoolSize) + { + _bufferPool.Push(buffer); + } + } +} diff --git a/src/TurboHTTP/Server/Hosting/TurboConfigurationBinder.cs b/src/TurboHTTP/Server/Hosting/TurboConfigurationBinder.cs deleted file mode 100644 index 500f38524..000000000 --- a/src/TurboHTTP/Server/Hosting/TurboConfigurationBinder.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Net; -using System.Security.Authentication; -using Microsoft.Extensions.Configuration; - -namespace TurboHTTP.Server.Hosting; - -internal static class TurboConfigurationBinder -{ - public static void Bind(TurboServerOptions options, IConfigurationSection section) - { - if (!section.Exists()) - { - return; - } - - BindHttpsDefaults(options, section.GetSection("HttpsDefaults")); - BindEndpoints(options, section.GetSection("Endpoints")); - } - - private static void BindHttpsDefaults(TurboServerOptions options, IConfigurationSection section) - { - if (!section.Exists()) - { - return; - } - - var sslProtocols = ParseSslProtocols(section["SslProtocols"]); - var handshakeTimeout = ParseTimeSpan(section["HandshakeTimeout"]); - - options.ConfigureHttpsDefaults(https => - { - if (sslProtocols != SslProtocols.None) - { - https.EnabledSslProtocols = sslProtocols; - } - - if (handshakeTimeout.HasValue) - { - https.HandshakeTimeout = handshakeTimeout.Value; - } - }); - } - - private static void BindEndpoints(TurboServerOptions options, IConfigurationSection section) - { - if (!section.Exists()) - { - return; - } - - foreach (var endpoint in section.GetChildren()) - { - var url = endpoint["Url"]; - if (url is null) - { - continue; - } - - var certSection = endpoint.GetSection("Certificate"); - var hasCert = certSection.Exists() && certSection["Path"] is not null; - var hasSslProtocols = endpoint["SslProtocols"] is not null; - var hasProtocols = endpoint["Protocols"] is not null; - - if (!hasCert && !hasSslProtocols && !hasProtocols) - { - options.Urls.Add(url); - continue; - } - - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - { - options.Urls.Add(url); - continue; - } - - var host = uri.Host; - IPAddress address; - - if (host == "*" || host == "+") - { - address = IPAddress.Any; - } - else if (IPAddress.TryParse(host, out var parsed)) - { - address = parsed; - } - else if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) - { - address = IPAddress.Loopback; - } - else - { - address = IPAddress.Any; - } - - var port = (ushort)uri.Port; - var protocols = ParseHttpProtocols(endpoint["Protocols"]); - var sslProtocols = ParseSslProtocols(endpoint["SslProtocols"]); - - options.Listen(address, port, listen => - { - if (protocols != HttpProtocols.None) - { - listen.Protocols = protocols; - } - - if (uri.Scheme == "https") - { - if (hasCert) - { - listen.UseHttps(certSection["Path"]!, certSection["Password"], https => - { - if (sslProtocols != SslProtocols.None) - { - https.EnabledSslProtocols = sslProtocols; - } - }); - } - else - { - listen.UseHttps(https => - { - if (sslProtocols != SslProtocols.None) - { - https.EnabledSslProtocols = sslProtocols; - } - }); - } - } - }); - } - } - - private static SslProtocols ParseSslProtocols(string? value) - { - if (value is null) - { - return SslProtocols.None; - } - - return Enum.Parse(value, ignoreCase: true); - } - - private static HttpProtocols ParseHttpProtocols(string? value) - { - if (value is null) - { - return HttpProtocols.None; - } - - return Enum.Parse(value, ignoreCase: true); - } - - private static TimeSpan? ParseTimeSpan(string? value) - { - if (value is null) - { - return null; - } - - return TimeSpan.Parse(value); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http1ConnectionOptions.cs b/src/TurboHTTP/Server/Http1ConnectionOptions.cs new file mode 100644 index 000000000..faf240c83 --- /dev/null +++ b/src/TurboHTTP/Server/Http1ConnectionOptions.cs @@ -0,0 +1,18 @@ +namespace TurboHTTP.Server; + +internal sealed record Http1ConnectionOptions +{ + public required ResolvedServerLimits Limits { get; init; } + + public required int MaxRequestLineLength { get; init; } + public required int MaxRequestTargetLength { get; init; } + public required int MaxPipelinedRequests { get; init; } + public required int MaxChunkExtensionLength { get; init; } + public required int MaxHeaderListSize { get; init; } + public required int MaxHeaderCount { get; init; } + public required bool AllowObsFold { get; init; } + public required TimeSpan BodyReadTimeout { get; init; } + public required int MaxBufferedBodySize { get; init; } + public required int ResponseBodyChunkSize { get; init; } + public required TimeSpan BodyConsumptionTimeout { get; init; } +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http1ConnectionOptionsExtensions.cs b/src/TurboHTTP/Server/Http1ConnectionOptionsExtensions.cs new file mode 100644 index 000000000..5c59cd1f1 --- /dev/null +++ b/src/TurboHTTP/Server/Http1ConnectionOptionsExtensions.cs @@ -0,0 +1,56 @@ +using System.Buffers; +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http10.Options; +using TurboHTTP.Protocol.Syntax.Http11.Options; + +namespace TurboHTTP.Server; + +internal static class Http1ConnectionOptionsExtensions +{ + public static BodyEncoderOptions ToBodyEncoderOptions(this Http1ConnectionOptions o) => new() + { + ChunkSize = o.ResponseBodyChunkSize + }; + + public static Http10ServerEncoderOptions ToHttp10EncoderOptions(this Http1ConnectionOptions o) => new() + { + WriteDateHeader = true, + MaxHeaderBytes = o.MaxHeaderListSize + }; + + public static Http10ServerDecoderOptions ToHttp10DecoderOptions(this Http1ConnectionOptions o) => new() + { + StreamingThreshold = o.MaxBufferedBodySize, + MaxBufferedBodySize = o.MaxBufferedBodySize, + MaxStreamedBodySize = o.Limits.MaxRequestBodySize, + MaxHeaderBytes = o.MaxHeaderListSize, + MaxHeaderCount = o.MaxHeaderCount, + HeaderLineMaxLength = o.MaxRequestLineLength, + RequestLineMaxLength = o.MaxRequestLineLength, + MaxRequestTargetLength = o.MaxRequestTargetLength, + AllowObsFold = o.AllowObsFold + }; + + public static Http11ServerEncoderOptions ToHttp11EncoderOptions(this Http1ConnectionOptions o) => new() + { + KeepAliveTimeout = o.Limits.KeepAliveTimeout, + RequestHeadersTimeout = o.Limits.RequestHeadersTimeout, + WriteDateHeader = true, + MaxHeaderBytes = o.MaxHeaderListSize + }; + + public static Http11ServerDecoderOptions ToHttp11DecoderOptions(this Http1ConnectionOptions o) => new() + { + MaxPipelinedRequests = o.MaxPipelinedRequests, + MaxChunkExtensionLength = o.MaxChunkExtensionLength, + StreamingThreshold = o.MaxBufferedBodySize, + MaxBufferedBodySize = o.MaxBufferedBodySize, + MaxStreamedBodySize = o.Limits.MaxRequestBodySize, + MaxHeaderBytes = o.MaxHeaderListSize, + MaxHeaderCount = o.MaxHeaderCount, + HeaderLineMaxLength = o.MaxRequestLineLength, + RequestLineMaxLength = o.MaxRequestLineLength, + MaxRequestTargetLength = o.MaxRequestTargetLength, + AllowObsFold = o.AllowObsFold + }; +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http1ServerOptions.cs b/src/TurboHTTP/Server/Http1ServerOptions.cs index 0eb4cd1ce..14ca8757a 100644 --- a/src/TurboHTTP/Server/Http1ServerOptions.cs +++ b/src/TurboHTTP/Server/Http1ServerOptions.cs @@ -1,14 +1,55 @@ namespace TurboHTTP.Server; +/// +/// HTTP/1.x-specific server configuration. +/// Controls request line parsing, pipelining, chunked-encoding limits, body read timeouts, +/// and data-rate enforcement. Nullable properties inherit from +/// when left at null. +/// public sealed class Http1ServerOptions { - public int MaxRequestLineLength { get; set; } = 8192; - public int MaxRequestTargetLength { get; set; } = 8192; + /// Gets or sets the maximum length of the HTTP request line (method + target + version). Default is 8 KiB. + public int MaxRequestLineLength { get; set; } = 8 * 1024; + + /// Gets or sets the maximum length of the request-target (URL path + query). Default is 8 KiB. + public int MaxRequestTargetLength { get; set; } = 8 * 1024; + + /// Gets or sets the maximum number of pipelined requests buffered per keep-alive connection. Default is 16. public int MaxPipelinedRequests { get; set; } = 16; - public int MaxChunkExtensionLength { get; set; } = 4096; + + /// Gets or sets the maximum length of chunked-encoding extensions per chunk. Default is 4 KiB. + public int MaxChunkExtensionLength { get; set; } = 4 * 1024; + + /// + /// Gets or sets the maximum request body size (in bytes) that is buffered fully in memory. + /// Bodies larger than this are exposed as a streaming pipe with back-pressure. Default is 64 KiB. + /// + public int MaxBufferedRequestBodySize { get; set; } = 64 * 1024; + + /// Gets or sets the timeout for reading the complete request body after headers are received. Default is 30 seconds. public TimeSpan BodyReadTimeout { get; set; } = TimeSpan.FromSeconds(30); - public long MaxRequestBodySize { get; set; } = 30_000_000; - public int MaxHeaderListSize { get; set; } = 32 * 1024; + + /// Gets or sets the maximum total size of all request headers in bytes, or null to inherit from . + public int? MaxHeaderListSize { get; set; } + + /// Gets or sets the maximum allowed request body size in bytes, or null to inherit from . + public long? MaxRequestBodySize { get; set; } + + /// Gets or sets the keep-alive idle timeout, or null to inherit from . public TimeSpan? KeepAliveTimeout { get; set; } + + /// Gets or sets the timeout for receiving the complete request headers, or null to inherit from . public TimeSpan? RequestHeadersTimeout { get; set; } -} + + /// Gets or sets the minimum acceptable request body data rate in bytes/second, or null to inherit from . + public double? MinRequestBodyDataRate { get; set; } + + /// Gets or sets the grace period before enforcing the minimum request body data rate, or null to inherit from . + public TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } + + /// Gets or sets the minimum acceptable response data rate in bytes/second, or null to inherit from . + public double? MinResponseDataRate { get; set; } + + /// Gets or sets the grace period before enforcing the minimum response data rate, or null to inherit from . + public TimeSpan? MinResponseDataRateGracePeriod { get; set; } +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http2ConnectionOptions.cs b/src/TurboHTTP/Server/Http2ConnectionOptions.cs new file mode 100644 index 000000000..70efb2997 --- /dev/null +++ b/src/TurboHTTP/Server/Http2ConnectionOptions.cs @@ -0,0 +1,23 @@ +namespace TurboHTTP.Server; + +internal sealed record Http2ConnectionOptions +{ + public required ResolvedServerLimits Limits { get; init; } + + public required int MaxConcurrentStreams { get; init; } + public required int InitialConnectionWindowSize { get; init; } + public required int InitialStreamWindowSize { get; init; } + public required int MaxStreamWindowSize { get; init; } + public required double WindowScaleThresholdMultiplier { get; init; } + public required bool EnableAdaptiveWindowScaling { get; init; } + public required int MaxFrameSize { get; init; } + public required int HeaderTableSize { get; init; } + public required int MaxHeaderListSize { get; init; } + public required int MaxHeaderCount { get; init; } + public required long MaxResponseBufferSize { get; init; } + public required int ResponseBodyChunkSize { get; init; } + public required TimeSpan BodyConsumptionTimeout { get; init; } + public required bool UseHuffman { get; init; } + public required TimeSpan KeepAlivePingDelay { get; init; } + public required TimeSpan KeepAlivePingTimeout { get; init; } +} diff --git a/src/TurboHTTP/Server/Http2ConnectionOptionsExtensions.cs b/src/TurboHTTP/Server/Http2ConnectionOptionsExtensions.cs new file mode 100644 index 000000000..04df661a9 --- /dev/null +++ b/src/TurboHTTP/Server/Http2ConnectionOptionsExtensions.cs @@ -0,0 +1,35 @@ +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http2.Options; + +namespace TurboHTTP.Server; + +internal static class Http2ConnectionOptionsExtensions +{ + public static BodyEncoderOptions ToBodyEncoderOptions(this Http2ConnectionOptions o) => new() + { + ChunkSize = o.ResponseBodyChunkSize + }; + + public static Http2ServerEncoderOptions ToEncoderOptions(this Http2ConnectionOptions o) => new() + { + MaxFrameSize = o.MaxFrameSize, + HeaderTableSize = o.HeaderTableSize, + WriteDateHeader = true, + MaxHeaderBytes = o.MaxHeaderListSize, + UseHuffman = o.UseHuffman + }; + + public static Http2ServerDecoderOptions ToDecoderOptions(this Http2ConnectionOptions o) => new() + { + HeaderTableSize = o.HeaderTableSize, + MaxConcurrentStreams = o.MaxConcurrentStreams, + MaxFieldSectionSize = o.MaxHeaderListSize, + MaxHeaderBytes = o.MaxHeaderListSize, + MaxHeaderCount = o.MaxHeaderCount, + InitialConnectionWindowSize = o.InitialConnectionWindowSize, + InitialStreamWindowSize = o.InitialStreamWindowSize, + MaxStreamWindowSize = o.MaxStreamWindowSize, + WindowScaleThresholdMultiplier = o.WindowScaleThresholdMultiplier, + EnableAdaptiveWindowScaling = o.EnableAdaptiveWindowScaling + }; +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http2ServerOptions.cs b/src/TurboHTTP/Server/Http2ServerOptions.cs index 4501a6ff8..f7c843569 100644 --- a/src/TurboHTTP/Server/Http2ServerOptions.cs +++ b/src/TurboHTTP/Server/Http2ServerOptions.cs @@ -1,17 +1,72 @@ namespace TurboHTTP.Server; +/// +/// HTTP/2-specific server configuration. +/// Controls stream concurrency, flow-control windows, HPACK table size, frame size, response buffering, +/// and keep-alive pings. Nullable properties inherit from +/// when left at null. +/// public sealed class Http2ServerOptions { + /// Gets or sets the maximum number of concurrent streams per HTTP/2 connection. Default is 100. public int MaxConcurrentStreams { get; set; } = 100; + + /// Gets or sets the initial HTTP/2 connection-level flow-control window size in bytes. Default is 1 MiB. public int InitialConnectionWindowSize { get; set; } = 1 * 1024 * 1024; + + /// Gets or sets the initial HTTP/2 stream-level flow-control window size in bytes. Default is 768 KiB. public int InitialStreamWindowSize { get; set; } = 768 * 1024; + + /// Upper bound the per-stream receive window may grow to under adaptive scaling, in bytes. Default is 8 MiB. + public int MaxStreamWindowSize { get; set; } = 8 * 1024 * 1024; + + /// Threshold multiplier for adaptive window growth. Higher values grow the window less eagerly. Default is 1.0. + public double WindowScaleThresholdMultiplier { get; set; } = 1.0; + + /// Enables server-side adaptive (BDP-based) receive-window scaling. When true, the per-stream receive window grows from up to based on measured throughput and RTT. Default is true. + public bool EnableAdaptiveWindowScaling { get; set; } = true; + + /// Gets or sets the maximum HTTP/2 frame size in bytes. Default is 16 KiB. public int MaxFrameSize { get; set; } = 16 * 1024; + + /// Gets or sets the HPACK dynamic header table size in bytes. Default is 4 KiB. public int HeaderTableSize { get; set; } = 4 * 1024; - public int MaxHeaderListSize { get; set; } = 32 * 1024; - public long MaxRequestBodySize { get; set; } = 30_000_000; - public long MaxResponseBufferSize { get; set; } = 64 * 1024; - public TimeSpan KeepAliveTimeout { get; set; } = TimeSpan.FromSeconds(130); - public TimeSpan RequestHeadersTimeout { get; set; } = TimeSpan.FromSeconds(30); - public int MinRequestBodyDataRate { get; set; } = 240; - public TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } = TimeSpan.FromSeconds(5); + + /// Gets or sets the maximum total size of request headers in bytes, or null to inherit from . + public int? MaxHeaderListSize { get; set; } + + /// Gets or sets the maximum size of the response write buffer in bytes, or null to inherit from . + public long? MaxResponseBufferSize { get; set; } + + /// Gets or sets the maximum allowed request body size in bytes, or null to inherit from . + public long? MaxRequestBodySize { get; set; } + + /// Gets or sets the keep-alive idle timeout, or null to inherit from . + public TimeSpan? KeepAliveTimeout { get; set; } + + /// Gets or sets the timeout for receiving the complete request headers, or null to inherit from . + public TimeSpan? RequestHeadersTimeout { get; set; } + + /// Gets or sets the minimum acceptable request body data rate in bytes/second, or null to inherit from . + public double? MinRequestBodyDataRate { get; set; } + + /// Gets or sets the grace period before enforcing the minimum request body data rate, or null to inherit from . + public TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } + + /// Gets or sets the minimum acceptable response data rate in bytes/second, or null to inherit from . + public double? MinResponseDataRate { get; set; } + + /// Gets or sets the grace period before enforcing the minimum response data rate, or null to inherit from . + public TimeSpan? MinResponseDataRateGracePeriod { get; set; } + + /// + /// Idle time after receiving the last frame before the server sends a keep-alive PING to detect dead connections. + /// Set to to disable server-initiated keep-alive pings (default). + /// + public TimeSpan KeepAlivePingDelay { get; set; } = Timeout.InfiniteTimeSpan; + + /// + /// Maximum time to wait for a PING ACK before closing the connection. Default is 20 seconds. + /// + public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20); } \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http3ConnectionOptions.cs b/src/TurboHTTP/Server/Http3ConnectionOptions.cs new file mode 100644 index 000000000..5718e6899 --- /dev/null +++ b/src/TurboHTTP/Server/Http3ConnectionOptions.cs @@ -0,0 +1,16 @@ +namespace TurboHTTP.Server; + +internal sealed record Http3ConnectionOptions +{ + public required ResolvedServerLimits Limits { get; init; } + + public required int MaxConcurrentStreams { get; init; } + public required int MaxHeaderListSize { get; init; } + public required int MaxHeaderCount { get; init; } + public required int QpackMaxTableCapacity { get; init; } + public required int QpackBlockedStreams { get; init; } + public required long MaxResponseBufferSize { get; init; } + public required int ResponseBodyChunkSize { get; init; } + public required TimeSpan BodyConsumptionTimeout { get; init; } + public required bool UseHuffman { get; init; } +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http3ConnectionOptionsExtensions.cs b/src/TurboHTTP/Server/Http3ConnectionOptionsExtensions.cs new file mode 100644 index 000000000..d0c3a4285 --- /dev/null +++ b/src/TurboHTTP/Server/Http3ConnectionOptionsExtensions.cs @@ -0,0 +1,29 @@ +using TurboHTTP.Protocol.Body; +using TurboHTTP.Protocol.Syntax.Http3.Options; + +namespace TurboHTTP.Server; + +internal static class Http3ConnectionOptionsExtensions +{ + public static BodyEncoderOptions ToBodyEncoderOptions(this Http3ConnectionOptions o) => new() + { + ChunkSize = o.ResponseBodyChunkSize + }; + + public static Http3ServerEncoderOptions ToEncoderOptions(this Http3ConnectionOptions o) => new() + { + WriteDateHeader = true, + QpackMaxTableCapacity = o.QpackMaxTableCapacity, + QpackBlockedStreams = o.QpackBlockedStreams, + MaxHeaderBytes = o.MaxHeaderListSize, + UseHuffman = o.UseHuffman + }; + + public static Http3ServerDecoderOptions ToDecoderOptions(this Http3ConnectionOptions o) => new() + { + MaxConcurrentStreams = o.MaxConcurrentStreams, + MaxFieldSectionSize = o.MaxHeaderListSize, + MaxHeaderBytes = o.MaxHeaderListSize, + MaxHeaderCount = o.MaxHeaderCount + }; +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Http3ServerOptions.cs b/src/TurboHTTP/Server/Http3ServerOptions.cs index b367673c4..e0c3ac76a 100644 --- a/src/TurboHTTP/Server/Http3ServerOptions.cs +++ b/src/TurboHTTP/Server/Http3ServerOptions.cs @@ -1,14 +1,46 @@ namespace TurboHTTP.Server; +/// +/// HTTP/3-specific server configuration. +/// Controls stream concurrency, QPACK compression, response buffering, and data-rate enforcement. +/// Nullable properties inherit from +/// when left at null. +/// public sealed class Http3ServerOptions { + /// Gets or sets the maximum number of concurrent streams per HTTP/3 connection. Default is 100. public int MaxConcurrentStreams { get; set; } = 100; - public int MaxHeaderListSize { get; set; } = 32 * 1024; + + /// Gets or sets the maximum total size of request headers in bytes, or null to inherit from . + public int? MaxHeaderListSize { get; set; } + + /// Gets or sets the QPACK dynamic table capacity in bytes. Default is 0 (dynamic table disabled). public int QpackMaxTableCapacity { get; set; } - public bool EnableWebTransport { get; set; } - public long MaxRequestBodySize { get; set; } = 30_000_000; - public TimeSpan KeepAliveTimeout { get; set; } = TimeSpan.FromSeconds(130); - public TimeSpan RequestHeadersTimeout { get; set; } = TimeSpan.FromSeconds(30); - public int MinRequestBodyDataRate { get; set; } = 240; - public TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } = TimeSpan.FromSeconds(5); -} + + /// Gets or sets the maximum number of blocked streams waiting for QPACK decoder instructions. Default is 100. + public int QpackBlockedStreams { get; set; } = 100; + + /// Gets or sets the maximum size of the per-stream response write buffer in bytes, or null to inherit from . + public long? MaxResponseBufferSize { get; set; } + + /// Gets or sets the maximum allowed request body size in bytes, or null to inherit from . + public long? MaxRequestBodySize { get; set; } + + /// Gets or sets the keep-alive idle timeout, or null to inherit from . + public TimeSpan? KeepAliveTimeout { get; set; } + + /// Gets or sets the timeout for receiving the complete request headers, or null to inherit from . + public TimeSpan? RequestHeadersTimeout { get; set; } + + /// Gets or sets the minimum acceptable request body data rate in bytes/second, or null to inherit from . + public double? MinRequestBodyDataRate { get; set; } + + /// Gets or sets the grace period before enforcing the minimum request body data rate, or null to inherit from . + public TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } + + /// Gets or sets the minimum acceptable response data rate in bytes/second, or null to inherit from . + public double? MinResponseDataRate { get; set; } + + /// Gets or sets the grace period before enforcing the minimum response data rate, or null to inherit from . + public TimeSpan? MinResponseDataRateGracePeriod { get; set; } +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/HttpProtocols.cs b/src/TurboHTTP/Server/HttpProtocols.cs index 9052c49b4..69cf847f0 100644 --- a/src/TurboHTTP/Server/HttpProtocols.cs +++ b/src/TurboHTTP/Server/HttpProtocols.cs @@ -2,18 +2,32 @@ namespace TurboHTTP.Server; +/// +/// Flags enumeration of HTTP protocol versions that a server endpoint may negotiate. +/// Multiple values can be combined; e.g. enables both. +/// [Flags] public enum HttpProtocols { + /// No protocol enabled. None = 0, + /// HTTP/1.0 and HTTP/1.1. Http1 = 1, + /// HTTP/2. Http2 = 2, + /// Both HTTP/1.x and HTTP/2 (ALPN negotiated over TLS or via upgrade for cleartext). Http1AndHttp2 = Http1 | Http2, + /// HTTP/3 over QUIC (requires HTTPS). Http3 = 4 } +/// Extension methods for . public static class HttpProtocolsExtensions { + /// + /// Converts the enabled protocol flags to the corresponding ALPN protocol identifiers, + /// ordered from highest to lowest preference (H3, H2, H1). + /// public static List ToAlpnProtocols(this HttpProtocols protocols) { var result = new List(); diff --git a/src/TurboHTTP/Server/ListenerBinding.cs b/src/TurboHTTP/Server/ListenerBinding.cs index e5c66e47d..cdc6992e8 100644 --- a/src/TurboHTTP/Server/ListenerBinding.cs +++ b/src/TurboHTTP/Server/ListenerBinding.cs @@ -2,9 +2,17 @@ namespace TurboHTTP.Server; +/// +/// Associates a set of listener configuration options with the factory that creates the +/// underlying transport listener (TCP or QUIC), and an optional structured-logging category +/// for per-connection log output. +/// public sealed class ListenerBinding { + /// Gets the transport-level listener options (e.g. host, port, TLS settings). public required ListenerOptions Options { get; init; } + /// Gets the factory used to instantiate the listener for these options. public required IListenerFactory Factory { get; init; } + /// Gets the logger category name used for connection-level logging, or null to disable. public string? ConnectionLoggingCategory { get; init; } } \ No newline at end of file diff --git a/src/TurboHTTP/Server/ResolvedServerLimits.cs b/src/TurboHTTP/Server/ResolvedServerLimits.cs new file mode 100644 index 000000000..1259430bb --- /dev/null +++ b/src/TurboHTTP/Server/ResolvedServerLimits.cs @@ -0,0 +1,11 @@ +namespace TurboHTTP.Server; + +internal readonly record struct ResolvedServerLimits( + long MaxRequestBodySize, + TimeSpan KeepAliveTimeout, + TimeSpan RequestHeadersTimeout, + double MinRequestBodyDataRate, + TimeSpan MinRequestBodyDataRateGracePeriod, + double MinResponseDataRate, + TimeSpan MinResponseDataRateGracePeriod, + int MaxResetStreamsPerWindow = 200); \ No newline at end of file diff --git a/src/TurboHTTP/Server/ServerOptionsProjections.cs b/src/TurboHTTP/Server/ServerOptionsProjections.cs new file mode 100644 index 000000000..ea71dedfa --- /dev/null +++ b/src/TurboHTTP/Server/ServerOptionsProjections.cs @@ -0,0 +1,89 @@ +namespace TurboHTTP.Server; + +internal static class ServerOptionsProjections +{ + public static Http1ConnectionOptions ToHttp1Options(this TurboServerOptions o) + => new() + { + Limits = ResolveLimits(o, o.Http1.MaxRequestBodySize, o.Http1.KeepAliveTimeout, + o.Http1.RequestHeadersTimeout, o.Http1.MinRequestBodyDataRate, + o.Http1.MinRequestBodyDataRateGracePeriod, o.Http1.MinResponseDataRate, + o.Http1.MinResponseDataRateGracePeriod), + MaxRequestLineLength = o.Http1.MaxRequestLineLength, + MaxRequestTargetLength = o.Http1.MaxRequestTargetLength, + MaxPipelinedRequests = o.Http1.MaxPipelinedRequests, + MaxChunkExtensionLength = o.Http1.MaxChunkExtensionLength, + MaxHeaderListSize = o.Http1.MaxHeaderListSize ?? o.Limits.MaxRequestHeadersTotalSize, + MaxHeaderCount = o.Limits.MaxRequestHeaderCount, + AllowObsFold = false, + BodyReadTimeout = o.Http1.BodyReadTimeout, + MaxBufferedBodySize = o.Http1.MaxBufferedRequestBodySize, + ResponseBodyChunkSize = o.ResponseBodyChunkSize, + BodyConsumptionTimeout = o.BodyConsumptionTimeout + }; + + public static Http2ConnectionOptions ToHttp2Options(this TurboServerOptions o) + => new() + { + Limits = ResolveLimits(o, o.Http2.MaxRequestBodySize, o.Http2.KeepAliveTimeout, + o.Http2.RequestHeadersTimeout, o.Http2.MinRequestBodyDataRate, + o.Http2.MinRequestBodyDataRateGracePeriod, o.Http2.MinResponseDataRate, + o.Http2.MinResponseDataRateGracePeriod), + MaxConcurrentStreams = o.Http2.MaxConcurrentStreams, + InitialConnectionWindowSize = o.Http2.InitialConnectionWindowSize, + InitialStreamWindowSize = o.Http2.InitialStreamWindowSize, + MaxStreamWindowSize = o.Http2.MaxStreamWindowSize, + WindowScaleThresholdMultiplier = o.Http2.WindowScaleThresholdMultiplier, + EnableAdaptiveWindowScaling = o.Http2.EnableAdaptiveWindowScaling, + MaxFrameSize = o.Http2.MaxFrameSize, + HeaderTableSize = o.Http2.HeaderTableSize, + MaxHeaderListSize = o.Http2.MaxHeaderListSize ?? o.Limits.MaxRequestHeadersTotalSize, + MaxHeaderCount = o.Limits.MaxRequestHeaderCount, + MaxResponseBufferSize = o.Http2.MaxResponseBufferSize ?? o.Limits.MaxResponseBufferSize, + ResponseBodyChunkSize = o.ResponseBodyChunkSize, + BodyConsumptionTimeout = o.BodyConsumptionTimeout, + UseHuffman = o.AllowResponseHeaderCompression, + KeepAlivePingDelay = o.Http2.KeepAlivePingDelay, + KeepAlivePingTimeout = o.Http2.KeepAlivePingTimeout + }; + + public static Http3ConnectionOptions ToHttp3Options(this TurboServerOptions o) + => new() + { + Limits = ResolveLimits(o, o.Http3.MaxRequestBodySize, o.Http3.KeepAliveTimeout, + o.Http3.RequestHeadersTimeout, o.Http3.MinRequestBodyDataRate, + o.Http3.MinRequestBodyDataRateGracePeriod, o.Http3.MinResponseDataRate, + o.Http3.MinResponseDataRateGracePeriod), + MaxConcurrentStreams = o.Http3.MaxConcurrentStreams, + MaxHeaderListSize = o.Http3.MaxHeaderListSize ?? o.Limits.MaxRequestHeadersTotalSize, + MaxHeaderCount = o.Limits.MaxRequestHeaderCount, + QpackMaxTableCapacity = o.Http3.QpackMaxTableCapacity, + QpackBlockedStreams = o.Http3.QpackBlockedStreams, + MaxResponseBufferSize = o.Http3.MaxResponseBufferSize ?? o.Limits.MaxResponseBufferSize, + ResponseBodyChunkSize = o.ResponseBodyChunkSize, + BodyConsumptionTimeout = o.BodyConsumptionTimeout, + UseHuffman = o.AllowResponseHeaderCompression + }; + + public static DataRateOptions ToRateMonitor(this Http1ConnectionOptions o) => RateOf(o.Limits); + public static DataRateOptions ToRateMonitor(this Http2ConnectionOptions o) => RateOf(o.Limits); + public static DataRateOptions ToRateMonitor(this Http3ConnectionOptions o) => RateOf(o.Limits); + + private static DataRateOptions RateOf(in ResolvedServerLimits l) + => new(l.MinRequestBodyDataRate, l.MinRequestBodyDataRateGracePeriod, + l.MinResponseDataRate, l.MinResponseDataRateGracePeriod); + + private static ResolvedServerLimits ResolveLimits( + TurboServerOptions o, + long? maxBody, TimeSpan? keepAlive, TimeSpan? headersTimeout, + double? minReqRate, TimeSpan? minReqGrace, double? minRespRate, TimeSpan? minRespGrace) + => new( + MaxRequestBodySize: maxBody ?? o.Limits.MaxRequestBodySize, + MaxResetStreamsPerWindow: o.Limits.MaxResetStreamsPerWindow, + KeepAliveTimeout: keepAlive ?? o.Limits.KeepAliveTimeout, + RequestHeadersTimeout: headersTimeout ?? o.Limits.RequestHeadersTimeout, + MinRequestBodyDataRate: minReqRate ?? o.Limits.MinRequestBodyDataRate, + MinRequestBodyDataRateGracePeriod: minReqGrace ?? o.Limits.MinRequestBodyDataRateGracePeriod, + MinResponseDataRate: minRespRate ?? o.Limits.MinResponseDataRate, + MinResponseDataRateGracePeriod: minRespGrace ?? o.Limits.MinResponseDataRateGracePeriod); +} diff --git a/src/TurboHTTP/Server/TransportBufferOptions.cs b/src/TurboHTTP/Server/TransportBufferOptions.cs new file mode 100644 index 000000000..138e50e25 --- /dev/null +++ b/src/TurboHTTP/Server/TransportBufferOptions.cs @@ -0,0 +1,97 @@ +namespace TurboHTTP.Server; + +/// +/// Controls backpressure thresholds on the read/write pipes between the OS socket +/// and the HTTP pipeline. These are applied per-connection for TCP and per-stream +/// for QUIC. Properties left at null fall back to the protocol-specific +/// default (TCP buffers one pipe per connection, QUIC one pipe per stream). +/// +public sealed class TransportBufferOptions +{ + /// + /// The number of bytes buffered on the inbound (read) pipe before the writer + /// pauses and signals backpressure to the OS. null uses the transport + /// default: TCP = 1 MiB (one pipe per connection), QUIC = 64 KiB (one pipe per stream). + /// + public long? InputPauseThreshold { get; set; } + + /// + /// The buffered byte count at which the inbound pipe resumes accepting data + /// after a pause. Must be less than or equal to . + /// null uses the transport default: TCP = 512 KiB, QUIC = 32 KiB. + /// + public long? InputResumeThreshold { get; set; } + + /// + /// The number of bytes buffered on the outbound (write) pipe before the writer + /// pauses and signals backpressure to the HTTP pipeline. + /// null uses the transport default of 64 KiB. + /// + public long? OutputPauseThreshold { get; set; } + + /// + /// The buffered byte count at which the outbound pipe resumes after a pause. + /// Must be less than or equal to . + /// null uses the transport default of 32 KiB. + /// + public long? OutputResumeThreshold { get; set; } + + /// + /// The minimum size of each buffer segment allocated by the pipe's memory pool. + /// Larger values reduce segment count but increase per-pipe memory. + /// null uses the transport default: TCP = 16 KiB, QUIC = 4 KiB (one pipe per stream). + /// + public int? MinimumSegmentSize { get; set; } + + internal ResolvedTransportBuffers ResolveTcp() => Resolve( + defaultInputPause: 1024 * 1024, + defaultInputResume: 512 * 1024, + defaultMinimumSegmentSize: 16 * 1024); + + internal ResolvedTransportBuffers ResolveQuic() => Resolve( + defaultInputPause: 64 * 1024, + defaultInputResume: 32 * 1024, + defaultMinimumSegmentSize: 4 * 1024); + + internal static ResolvedTransportBuffers TcpDefaults { get; } = new TransportBufferOptions().ResolveTcp(); + + internal static ResolvedTransportBuffers QuicDefaults { get; } = new TransportBufferOptions().ResolveQuic(); + + private ResolvedTransportBuffers Resolve(long defaultInputPause, long defaultInputResume, int defaultMinimumSegmentSize) + { + var resolved = new ResolvedTransportBuffers( + InputPauseThreshold: InputPauseThreshold ?? defaultInputPause, + InputResumeThreshold: InputResumeThreshold ?? defaultInputResume, + OutputPauseThreshold: OutputPauseThreshold ?? 64 * 1024, + OutputResumeThreshold: OutputResumeThreshold ?? 32 * 1024, + MinimumSegmentSize: MinimumSegmentSize ?? defaultMinimumSegmentSize); + + if (resolved.InputResumeThreshold > resolved.InputPauseThreshold) + { + throw new InvalidOperationException( + string.Concat( + "TransportBufferOptions: InputResumeThreshold (", resolved.InputResumeThreshold.ToString(), + ") must not exceed InputPauseThreshold (", resolved.InputPauseThreshold.ToString(), ").")); + } + + if (resolved.OutputResumeThreshold > resolved.OutputPauseThreshold) + { + throw new InvalidOperationException( + string.Concat( + "TransportBufferOptions: OutputResumeThreshold (", resolved.OutputResumeThreshold.ToString(), + ") must not exceed OutputPauseThreshold (", resolved.OutputPauseThreshold.ToString(), ").")); + } + + return resolved; + } +} + +/// +/// Transport buffer thresholds with all defaults applied, ready to project onto listener options. +/// +internal readonly record struct ResolvedTransportBuffers( + long InputPauseThreshold, + long InputResumeThreshold, + long OutputPauseThreshold, + long OutputResumeThreshold, + int MinimumSegmentSize); diff --git a/src/TurboHTTP/Server/TurboHttpsOptions.cs b/src/TurboHTTP/Server/TurboHttpsOptions.cs index 1e92b1444..e36393271 100644 --- a/src/TurboHTTP/Server/TurboHttpsOptions.cs +++ b/src/TurboHTTP/Server/TurboHttpsOptions.cs @@ -5,14 +5,33 @@ namespace TurboHTTP.Server; +/// +/// TLS/HTTPS configuration applied to a single server endpoint. Specifies the server +/// certificate, optional client-certificate policy, and TLS handshake parameters. +/// public sealed class TurboHttpsOptions { + /// Gets or sets the in-memory server certificate. Takes precedence over when both are set. public X509Certificate2? ServerCertificate { get; set; } + /// Gets or sets the file-system path to a PEM or PKCS#12 certificate file. Ignored when is set. public string? CertificatePath { get; set; } + /// Gets or sets the password used to decrypt the certificate file at . Ignored when loading PEM files without encryption. public string? CertificatePassword { get; set; } + /// + /// Gets or sets the TLS protocol versions the server will accept. + /// Default is , which lets the OS choose a secure default. + /// public SslProtocols EnabledSslProtocols { get; set; } = SslProtocols.None; + /// Gets or sets a callback used to validate the client certificate when client authentication is requested. public RemoteCertificateValidationCallback? ClientCertificateValidationCallback { get; set; } + /// Gets or sets the maximum time allowed for the TLS handshake to complete. Default is 10 seconds. public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(10); + /// Gets or sets the client certificate requirement mode. Default is . public ClientCertificateMode ClientCertificateMode { get; set; } = ClientCertificateMode.NoCertificate; + /// + /// Gets or sets a per-connection certificate selector invoked with the TLS SNI host name. + /// Takes precedence over when non-null. + /// Not supported for HTTP/3 (QUIC) endpoints. + /// public Func? ServerCertificateSelector { get; set; } } \ No newline at end of file diff --git a/src/TurboHTTP/Server/TurboListenOptions.cs b/src/TurboHTTP/Server/TurboListenOptions.cs index 2ac0349fe..fd848f20f 100644 --- a/src/TurboHTTP/Server/TurboListenOptions.cs +++ b/src/TurboHTTP/Server/TurboListenOptions.cs @@ -1,27 +1,38 @@ using System.Net; using System.Security.Cryptography.X509Certificates; +using Akka.Routing; namespace TurboHTTP.Server; +/// +/// Configures a single server listen endpoint: the IP address, port, HTTP protocols, and +/// optional TLS settings. Obtained from overloads. +/// public sealed class TurboListenOptions(IPAddress address, ushort port) { + /// Gets the IP address this endpoint listens on. public IPAddress Address { get; } = address; + /// Gets the TCP/UDP port this endpoint listens on. public ushort Port { get; } = port; + /// Gets or sets the HTTP protocol versions enabled on this endpoint. Default is HTTP/1.x and HTTP/2. public HttpProtocols Protocols { get; set; } = HttpProtocols.Http1AndHttp2; internal bool IsHttps => HttpsOptions is not null; internal TurboHttpsOptions? HttpsOptions { get; private set; } + /// Enables HTTPS using the default (certificate must be supplied via ). public void UseHttps() { HttpsOptions = new TurboHttpsOptions(); } + /// Enables HTTPS using the provided . public void UseHttps(X509Certificate2 certificate) { HttpsOptions = new TurboHttpsOptions { ServerCertificate = certificate }; } + /// Enables HTTPS by loading the certificate from the file at , optionally decrypting with . public void UseHttps(string path, string? password = null) { HttpsOptions = new TurboHttpsOptions @@ -31,18 +42,21 @@ public void UseHttps(string path, string? password = null) }; } + /// Enables HTTPS and applies additional TLS settings via . public void UseHttps(Action configure) { HttpsOptions = new TurboHttpsOptions(); configure(HttpsOptions); } + /// Enables HTTPS with and applies additional TLS settings via . public void UseHttps(X509Certificate2 certificate, Action configure) { HttpsOptions = new TurboHttpsOptions { ServerCertificate = certificate }; configure(HttpsOptions); } + /// Enables HTTPS from a certificate file at and applies additional TLS settings via . public void UseHttps(string path, string? password, Action configure) { HttpsOptions = new TurboHttpsOptions @@ -53,13 +67,24 @@ public void UseHttps(string path, string? password, Action co configure(HttpsOptions); } + /// + /// Gets the transport-level buffer options for this endpoint. Controls backpressure + /// thresholds on the read/write pipes between the OS socket and the HTTP pipeline. + /// Defaults are protocol-optimized: TCP uses larger buffers (one pipe per connection), + /// QUIC uses smaller buffers (one pipe per stream). + /// Set to null to use the protocol-specific defaults. + /// + public TransportBufferOptions? Transport { get; set; } + internal string? ConnectionLoggingCategory { get; private set; } + /// Enables per-connection logging under the default category TurboHTTP.Server.ConnectionLogging. public void UseConnectionLogging() { ConnectionLoggingCategory = "TurboHTTP.Server.ConnectionLogging"; } + /// Enables per-connection logging under the specified category. public void UseConnectionLogging(string loggerName) { ConnectionLoggingCategory = loggerName; diff --git a/src/TurboHTTP/Server/TurboServer.cs b/src/TurboHTTP/Server/TurboServer.cs index 1870aeacc..3eadcc97c 100644 --- a/src/TurboHTTP/Server/TurboServer.cs +++ b/src/TurboHTTP/Server/TurboServer.cs @@ -2,7 +2,6 @@ using Akka.Actor; using Akka.Configuration; using Akka.Hosting.Logging; -using Akka.Streams; using Akka.Streams.Dsl; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; @@ -16,6 +15,11 @@ namespace TurboHTTP.Server; +/// +/// TurboHTTP's ASP.NET Core implementation. Manages an Akka actor system, +/// resolves configured endpoints, and routes incoming connections through the application pipeline. +/// Register via . +/// public sealed class TurboServer : IServer { private static readonly Config LoggingHocon = ConfigurationFactory.ParseString( @@ -30,6 +34,7 @@ public sealed class TurboServer : IServer private bool _ownsSystem; private IActorRef _supervisor = ActorRefs.Nobody; + /// Initializes a new with the provided options, logger factory, and service provider. public TurboServer(IOptions options, ILoggerFactory loggerFactory, IServiceProvider services) { _options = options.Value; @@ -40,12 +45,19 @@ public TurboServer(IOptions options, ILoggerFactory loggerFa _features.Set(addressesFeature); } + /// Gets the server feature collection, including the populated after start. public IFeatureCollection Features => _features; + /// + /// Starts the server: resolves endpoints, creates the Akka actor system if none is registered, + /// binds listeners, and populates with bound addresses. + /// public async Task StartAsync( IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull { + _options.Validate(); + _system = _services.GetService(); if (_system is null) { @@ -56,82 +68,100 @@ public async Task StartAsync( _ownsSystem = true; } - var materializer = _system.Materializer(); + var resolver = new EndpointResolver(); + var resolvedEndpoints = resolver.Resolve(_options); - var parallelism = _options.Http2.MaxConcurrentStreams; var bridgeFlow = Flow.FromGraph(new ApplicationBridgeStage( application, - parallelism, + int.MaxValue, _options.HandlerTimeout, _options.HandlerGracePeriod)); - var resolver = new EndpointResolver(); - var resolvedEndpoints = resolver.Resolve(_options); + _supervisor = _system.ActorOf( + Props.Create(() => new ServerSupervisorActor()), + "turbo-server"); + + var listenersReady = await _supervisor.Ask( + new ServerSupervisorActor.StartServer(bridgeFlow, _options, resolvedEndpoints), + TimeSpan.FromSeconds(30), + cancellationToken); var addressesFeature = _features.Get()!; - foreach (var endpoint in resolvedEndpoints) + for (var i = 0; i < resolvedEndpoints.Count; i++) { - var opts = endpoint.Options; - var scheme = (opts is TcpListenerOptions tcp && tcp.ServerCertificate is not null) ? "https" : "http"; - var host = opts.Host ?? "localhost"; - if (host == "0.0.0.0" || host == "::") + var opts = resolvedEndpoints[i].Options; + var scheme = opts is TcpListenerOptions { ServerCertificate: not null } ? "https" : "http"; + var host = opts.Host; + if (host is "0.0.0.0" or "::") { host = "localhost"; } - addressesFeature.Addresses.Add(string.Concat(scheme, "://", host, ":", opts.Port.ToString())); - } - var listenerProps = new List(resolvedEndpoints.Count); - foreach (var endpoint in resolvedEndpoints) - { - listenerProps.Add(ListenerActor.Create( - endpoint.Factory, - endpoint.Options, - _options, - bridgeFlow, - _services, - materializer, - endpoint.ConnectionLoggingCategory)); + var port = i < listenersReady.BoundPorts.Count ? listenersReady.BoundPorts[i] : opts.Port; + addressesFeature.Addresses.Add(string.Concat(scheme, "://", host, ":", port.ToString())); } - _supervisor = _system.ActorOf( - Props.Create(() => new ServerSupervisorActor()), - "turbo-server"); - - await _supervisor.Ask( - new ServerSupervisorActor.StartListeners(listenerProps), - TimeSpan.FromSeconds(30), - cancellationToken); + if (_ownsSystem) + { + var cs = CoordinatedShutdown.Get(_system); - var cs = CoordinatedShutdown.Get(_system); + cs.AddTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "turbo-stop-accepting", () => + { + _supervisor.Tell(new ServerSupervisorActor.StopAccepting()); + return Task.FromResult(Done.Instance); + }); - cs.AddTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "turbo-stop-accepting", () => - { - _supervisor.Tell(new ServerSupervisorActor.StopAccepting()); - return Task.FromResult(Done.Instance); - }); + var drainTask = Task.CompletedTask; - cs.AddTask(CoordinatedShutdown.PhaseServiceUnbind, "turbo-goaway", () => - { - _supervisor.Tell(new ServerSupervisorActor.BeginDrain(_options.GracefulShutdownTimeout)); - return Task.FromResult(Done.Instance); - }); + cs.AddTask(CoordinatedShutdown.PhaseServiceUnbind, "turbo-goaway", () => + { + drainTask = _supervisor.Ask( + new ServerSupervisorActor.BeginDrain(_options.GracefulShutdownTimeout), + _options.GracefulShutdownTimeout); + return Task.FromResult(Done.Instance); + }); - cs.AddTask(CoordinatedShutdown.PhaseServiceRequestsDone, "turbo-drain", async () => - { - await Task.Delay(_options.GracefulShutdownTimeout, CancellationToken.None); - return Done.Instance; - }); + cs.AddTask(CoordinatedShutdown.PhaseServiceRequestsDone, "turbo-drain", async () => + { + try + { + await drainTask; + } + catch + { + // drain may timeout if connections don't close gracefully + } + + return Done.Instance; + }); + } } + /// + /// Stops the server gracefully. If the server owns the actor system it runs a coordinated + /// shutdown; otherwise it drains in-flight requests and stops the supervisor actor. + /// public async Task StopAsync(CancellationToken cancellationToken) { - if (_system is not null) + if (_system is null) + { + return; + } + + if (_ownsSystem) { await CoordinatedShutdown.Get(_system).Run(CoordinatedShutdown.ClrExitReason.Instance); } + else + { + _supervisor.Tell(new ServerSupervisorActor.StopAccepting()); + _supervisor.Tell(new ServerSupervisorActor.BeginDrain(_options.GracefulShutdownTimeout)); + await Task.Delay(_options.GracefulShutdownTimeout, cancellationToken); + await _supervisor.GracefulStop(_options.GracefulShutdownTimeout); + } } + /// Disposes the actor system if this instance owns it. public void Dispose() { if (_ownsSystem) @@ -141,8 +171,3 @@ public void Dispose() } } -internal sealed class ServerAddressesFeature : IServerAddressesFeature -{ - public ICollection Addresses { get; } = new List(); - public bool PreferHostingUrls { get; set; } -} diff --git a/src/TurboHTTP/Server/TurboServerLimits.cs b/src/TurboHTTP/Server/TurboServerLimits.cs index d24b5e25a..f309e368f 100644 --- a/src/TurboHTTP/Server/TurboServerLimits.cs +++ b/src/TurboHTTP/Server/TurboServerLimits.cs @@ -1,16 +1,42 @@ namespace TurboHTTP.Server; +/// +/// Server-wide limits applied to all connections and protocols. Individual protocol options +/// (, , ) +/// can override these values per protocol; null overrides fall back to the values here. +/// public sealed class TurboServerLimits { + /// Gets or sets the maximum number of concurrent connections the server accepts. 0 means unlimited. Default is 0. public int MaxConcurrentConnections { get; set; } - public int MaxConcurrentUpgradedConnections { get; set; } - public long MaxRequestBodySize { get; set; } = 30 * 1024 * 1024; + ///Gets or sets the default maximum request body size in bytes for all protocols. Default is 30,000,000 bytes (~28.6 MiB), matching Kestrel. + public long MaxRequestBodySize { get; set; } = 30_000_000; + /// Gets or sets the maximum number of headers allowed in a single request. Default is 100. public int MaxRequestHeaderCount { get; set; } = 100; + /// Gets or sets the maximum combined size in bytes of all request headers. Default is 32 KiB. public int MaxRequestHeadersTotalSize { get; set; } = 32 * 1024; + /// Gets or sets the maximum size of the per-stream response write buffer in bytes. Default is 64 KiB. + public long MaxResponseBufferSize { get; set; } = 64 * 1024; + /// Gets or sets the maximum size of the transport input buffer in bytes before back-pressure is applied. Default is 1 MiB. Set to null for unlimited. + public long? MaxRequestBufferSize { get; set; } = 1024 * 1024; + + /// + /// HTTP/2 Rapid Reset (CVE-2023-44487) mitigation: the maximum number of client-initiated stream + /// resets tolerated within a sliding window before the connection is closed with + /// GOAWAY(ENHANCE_YOUR_CALM). Aligned with Kestrel's default. Set to 0 to disable the mitigation. + /// + public int MaxResetStreamsPerWindow { get; set; } = 200; + + /// Gets or sets the keep-alive idle timeout for HTTP/1.x and HTTP/2 connections. Default is 130 seconds. public TimeSpan KeepAliveTimeout { get; set; } = TimeSpan.FromSeconds(130); + /// Gets or sets the maximum time to receive the complete request headers after the connection is accepted. Default is 30 seconds. public TimeSpan RequestHeadersTimeout { get; set; } = TimeSpan.FromSeconds(30); - public double MinRequestBodyDataRate { get; set; } + /// Gets or sets the minimum acceptable request body data rate in bytes/second. Default is 240 bytes/second. + public double MinRequestBodyDataRate { get; set; } = 240; + /// Gets or sets the grace period before the minimum request body data rate is enforced. Default is 5 seconds. public TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } = TimeSpan.FromSeconds(5); - public double MinResponseDataRate { get; set; } + /// Gets or sets the minimum acceptable response data rate in bytes/second. Default is 240 bytes/second. + public double MinResponseDataRate { get; set; } = 240; + /// Gets or sets the grace period before the minimum response data rate is enforced. Default is 5 seconds. public TimeSpan MinResponseDataRateGracePeriod { get; set; } = TimeSpan.FromSeconds(5); } diff --git a/src/TurboHTTP/Server/TurboServerOptions.cs b/src/TurboHTTP/Server/TurboServerOptions.cs index e864e55f1..b27c133b7 100644 --- a/src/TurboHTTP/Server/TurboServerOptions.cs +++ b/src/TurboHTTP/Server/TurboServerOptions.cs @@ -1,56 +1,88 @@ using System.Net; using Servus.Akka.Transport; -using Servus.Akka.Transport.Tcp.Listener; using Servus.Akka.Transport.Quic.Listener; +using Servus.Akka.Transport.Tcp.Listener; namespace TurboHTTP.Server; +/// +/// Top-level configuration for . Controls server-wide limits, timeouts, +/// protocol-specific sub-options, and endpoint bindings. Configure via +/// or DI options. +/// public sealed class TurboServerOptions { + /// Gets the server-wide limits applied to all connections and requests. public TurboServerLimits Limits { get; } = new(); + /// Gets or sets the time allowed for in-flight requests to complete during shutdown. Default is 30 seconds. public TimeSpan GracefulShutdownTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Gets or sets the maximum time a request handler may run before it is cancelled. Default is 30 seconds. public TimeSpan HandlerTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Gets or sets additional time granted to handlers after the handler timeout fires to clean up. Default is 5 seconds. public TimeSpan HandlerGracePeriod { get; set; } = TimeSpan.FromSeconds(5); - public int BodyBufferThreshold { get; set; } = 64 * 1024; + /// Gets or sets the timeout for the application to consume the complete request body. Default is 30 seconds. public TimeSpan BodyConsumptionTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Gets or sets the size of each chunk written to the response body stream. Default is 16 KiB. public int ResponseBodyChunkSize { get; set; } = 16 * 1024; + ///Gets or sets the coalesce factor for outbound writes. Frames are merged up to factor × 16 KiB bytes per transport write. Higher values improve throughput under concurrent load. Default is 32. + public int MaxOutboundCoalesceCount { get; set; } = 32; + + /// Gets or sets whether response headers may use Huffman compression (HPACK/QPACK). Disabling mitigates CRIME/BREACH-style side-channel attacks. Default is true. + public bool AllowResponseHeaderCompression { get; set; } = true; + + /// Gets the HTTP/1.x-specific configuration options. public Http1ServerOptions Http1 { get; } = new(); + + /// Gets the HTTP/2-specific configuration options. public Http2ServerOptions Http2 { get; } = new(); + + /// Gets the HTTP/3-specific configuration options. public Http3ServerOptions Http3 { get; } = new(); + /// Gets the collection of pre-built listener bindings added via or similar overloads. public IList Endpoints { get; } = new List(); + /// Adds a TCP listener binding for the given . public void Bind(TcpListenerOptions options) { Endpoints.Add(new ListenerBinding { Options = options, Factory = new TcpListenerFactory() }); } + /// Adds a QUIC listener binding for the given . public void Bind(QuicListenerOptions options) { Endpoints.Add(new ListenerBinding { Options = options, Factory = new QuicListenerFactory() }); } + /// Adds a cleartext TCP listener on the specified and . public void BindTcp(string host, ushort port) => Bind(new TcpListenerOptions { Host = host, Port = port }); internal IList ListenOptions { get; } = new List(); internal Action? HttpsDefaultsCallback { get; private set; } internal Action? EndpointDefaultsCallback { get; private set; } + /// Gets the collection of URL strings (e.g. "https://0.0.0.0:443") resolved to listener bindings at startup. public IList Urls { get; } = new List(); + /// Registers a callback applied to the of every HTTPS endpoint before it is bound. public void ConfigureHttpsDefaults(Action configure) { HttpsDefaultsCallback = configure; } + /// Registers a callback applied to every endpoint's before it is bound. public void ConfigureEndpointDefaults(Action configure) { EndpointDefaultsCallback = configure; } + /// Adds a listen endpoint on the given and with default options. public void Listen(IPAddress address, ushort port) { var listenOptions = new TurboListenOptions(address, port); @@ -58,6 +90,7 @@ public void Listen(IPAddress address, ushort port) ListenOptions.Add(listenOptions); } + /// Adds a listen endpoint on the given and , applying to the resulting options. public void Listen(IPAddress address, ushort port, Action configure) { var listenOptions = new TurboListenOptions(address, port); @@ -66,6 +99,7 @@ public void Listen(IPAddress address, ushort port, Action co ListenOptions.Add(listenOptions); } + /// Parses (e.g. "http://0.0.0.0:80") and adds the resulting listen endpoint. public void Listen(string url) { try @@ -80,6 +114,7 @@ public void Listen(string url) } } + /// Parses and adds the resulting listen endpoint, applying to the options. public void Listen(string url, Action configure) { try @@ -95,28 +130,52 @@ public void Listen(string url, Action configure) } } + /// Adds a listen endpoint on the loopback address (127.0.0.1) at . public void ListenLocalhost(ushort port) { Listen(IPAddress.Loopback, port); } + /// Adds a listen endpoint on the loopback address at , applying to the options. public void ListenLocalhost(ushort port, Action configure) { Listen(IPAddress.Loopback, port, configure); } + /// Adds a listen endpoint on all network interfaces (0.0.0.0) at . public void ListenAnyIP(ushort port) { Listen(IPAddress.Any, port); } + /// Adds a listen endpoint on all network interfaces at , applying to the options. public void ListenAnyIP(ushort port, Action configure) { Listen(IPAddress.Any, port, configure); } + /// Adds a listener binding for the given using the supplied . public void Bind(ListenerOptions options, IListenerFactory factory) { Endpoints.Add(new ListenerBinding { Options = options, Factory = factory }); } + + internal void Validate() + { + ArgumentOutOfRangeException.ThrowIfNegative(Limits.MaxRequestBodySize); + ArgumentOutOfRangeException.ThrowIfLessThan(Limits.MaxRequestHeadersTotalSize, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(Limits.MaxRequestHeaderCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(Limits.KeepAliveTimeout, TimeSpan.Zero); + + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(HandlerTimeout, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(HandlerGracePeriod, TimeSpan.Zero); + + ArgumentOutOfRangeException.ThrowIfLessThan(Http2.MaxConcurrentStreams, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(Http2.MaxFrameSize, 16 * 1024); + ArgumentOutOfRangeException.ThrowIfGreaterThan(Http2.MaxFrameSize, 16 * 1024 * 1024 - 1); + ArgumentOutOfRangeException.ThrowIfLessThan(Http2.InitialStreamWindowSize, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(Http2.InitialConnectionWindowSize, 1); + + ArgumentOutOfRangeException.ThrowIfLessThan(Http3.MaxConcurrentStreams, 1); + } } \ No newline at end of file diff --git a/src/TurboHTTP/Server/TurboServerWebHostBuilderExtensions.cs b/src/TurboHTTP/Server/TurboServerWebHostBuilderExtensions.cs index 531b6f687..e115983f1 100644 --- a/src/TurboHTTP/Server/TurboServerWebHostBuilderExtensions.cs +++ b/src/TurboHTTP/Server/TurboServerWebHostBuilderExtensions.cs @@ -5,8 +5,13 @@ namespace TurboHTTP.Server; +/// Extension methods for to register TurboHTTP as the ASP.NET Core server. public static class TurboServerWebHostBuilderExtensions { + /// + /// Replaces the registered with and optionally + /// applies to . + /// public static IHostBuilder UseTurboHttp( this IHostBuilder builder, Action? configure = null) diff --git a/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs b/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs index 2d7ae3570..7549e58e4 100644 --- a/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs +++ b/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs @@ -4,7 +4,7 @@ using TurboHTTP.Diagnostics; using TurboHTTP.Streams.Stages.Features; using TurboHTTP.Streams.Stages.Client; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams; @@ -12,7 +12,7 @@ namespace TurboHTTP.Streams; /// Composes the BidiFlow feature stack on top of a protocol engine flow. /// Stacking order (outermost → innermost): /// -/// TracingBidiStage — root "TurboHTTP.Request" activity lifecycle +/// TracingBidiStage — root "TurboHTTP.ClientRequest" activity lifecycle /// User Handlers — HandlerBidiStage per TurboHandler (FIFO: [0] outermost) /// RedirectBidiStage — RFC 9110 §15.4, internal feedback loop /// CookieBidiStage — RFC 6265 §5.3–§5.4 @@ -45,7 +45,7 @@ internal static Flow Build( // and captures Alt-Svc headers from responses before other features process them. if (descriptor.AltSvcCache is not null) { - layers.Add(new AltSvcBidiStage(descriptor.AltSvcCache)); + layers.Add(new AltSvcBidiStage(descriptor.AltSvcCache, descriptor.UseProxy, descriptor.Proxy)); } if (descriptor.AutomaticDecompression || descriptor.CompressionPolicy is not null) diff --git a/src/TurboHTTP/Streams/Http10ServerEngine.cs b/src/TurboHTTP/Streams/Http10ServerEngine.cs index cbff0032f..9d4fe53d1 100644 --- a/src/TurboHTTP/Streams/Http10ServerEngine.cs +++ b/src/TurboHTTP/Streams/Http10ServerEngine.cs @@ -8,22 +8,15 @@ namespace TurboHTTP.Streams; -internal sealed class Http10ServerEngine : IServerProtocolEngine +internal sealed class Http10ServerEngine(TurboServerOptions options) : IServerProtocolEngine { - private readonly TurboServerOptions _options; - - public Http10ServerEngine(TurboServerOptions options) - { - _options = options; - } - public Version ProtocolVersion => new(1, 0); public BidiFlow CreateFlow(IServiceProvider? services = null) { return BidiFlow.FromGraph(GraphDsl.Create(b => { - var connection = b.Add(new Http10ServerConnectionStage(_options, services)); + var connection = b.Add(new Http10ServerConnectionStage(options, services)); return new BidiShape< ITransportInbound, diff --git a/src/TurboHTTP/Streams/Http11ServerEngine.cs b/src/TurboHTTP/Streams/Http11ServerEngine.cs index a6f4c0d43..6db785113 100644 --- a/src/TurboHTTP/Streams/Http11ServerEngine.cs +++ b/src/TurboHTTP/Streams/Http11ServerEngine.cs @@ -8,22 +8,15 @@ namespace TurboHTTP.Streams; -internal sealed class Http11ServerEngine : IServerProtocolEngine +internal sealed class Http11ServerEngine(TurboServerOptions options) : IServerProtocolEngine { - private readonly TurboServerOptions _options; - - public Http11ServerEngine(TurboServerOptions options) - { - _options = options; - } - public Version ProtocolVersion => new(1, 1); public BidiFlow CreateFlow(IServiceProvider? services = null) { return BidiFlow.FromGraph(GraphDsl.Create(b => { - var connection = b.Add(new Http11ServerConnectionStage(_options, services)); + var connection = b.Add(new Http11ServerConnectionStage(options, services)); return new BidiShape< ITransportInbound, diff --git a/src/TurboHTTP/Streams/Http20ServerEngine.cs b/src/TurboHTTP/Streams/Http20ServerEngine.cs index 3de5a4239..2b1bf09b4 100644 --- a/src/TurboHTTP/Streams/Http20ServerEngine.cs +++ b/src/TurboHTTP/Streams/Http20ServerEngine.cs @@ -8,22 +8,15 @@ namespace TurboHTTP.Streams; -internal sealed class Http20ServerEngine : IServerProtocolEngine +internal sealed class Http20ServerEngine(TurboServerOptions options) : IServerProtocolEngine { - private readonly TurboServerOptions _options; - - public Http20ServerEngine(TurboServerOptions options) - { - _options = options; - } - public Version ProtocolVersion => new(2, 0); public BidiFlow CreateFlow(IServiceProvider? services = null) { return BidiFlow.FromGraph(GraphDsl.Create(b => { - var connection = b.Add(new Http20ServerConnectionStage(_options, services)); + var connection = b.Add(new Http20ServerConnectionStage(options, services)); return new BidiShape< ITransportInbound, diff --git a/src/TurboHTTP/Streams/Http30ServerEngine.cs b/src/TurboHTTP/Streams/Http30ServerEngine.cs index 739ad0e7d..43b9673de 100644 --- a/src/TurboHTTP/Streams/Http30ServerEngine.cs +++ b/src/TurboHTTP/Streams/Http30ServerEngine.cs @@ -8,22 +8,15 @@ namespace TurboHTTP.Streams; -internal sealed class Http30ServerEngine : IServerProtocolEngine +internal sealed class Http30ServerEngine(TurboServerOptions options) : IServerProtocolEngine { - private readonly TurboServerOptions _options; - - public Http30ServerEngine(TurboServerOptions options) - { - _options = options; - } - public Version ProtocolVersion => new(3, 0); public BidiFlow CreateFlow(IServiceProvider? services = null) { return BidiFlow.FromGraph(GraphDsl.Create(b => { - var connection = b.Add(new Http30ServerConnectionStage(_options, services)); + var connection = b.Add(new Http30ServerConnectionStage(options, services)); return new BidiShape< ITransportInbound, diff --git a/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs b/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs index 2a3c5b2f2..b5f6d7831 100644 --- a/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs +++ b/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs @@ -1,173 +1,66 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; using Akka; using Akka.Actor; using Akka.Event; using Akka.Streams; using Akka.Streams.Dsl; using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Servus.Akka.Transport; -using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using TurboHTTP.Server; namespace TurboHTTP.Streams.Lifecycle; -internal enum ConnectionCompletionReason -{ - Normal, - Error, - Timeout, - ServerShutdown -} - internal sealed class ConnectionActor : ReceiveActor { - private readonly ILoggingAdapter _log = Context.GetLogger(); - private readonly string _connectionId; - private SharedKillSwitch? _killSwitch; - private bool _draining; - private readonly CancellationTokenSource _cts = new(); - private long _connectionTimestamp; - private Activity? _connectionActivity; - - public sealed record Materialize( - Flow ConnectionFlow, - IServerProtocolEngine Engine, - Flow BridgeFlow, - IServiceProvider Services, - IMaterializer Materializer, - string? ConnectionLoggingCategory = null, - long ConnectionTimestamp = 0, - Activity? ConnectionActivity = null); - - public sealed record GracefulStop(TimeSpan Timeout); - - public sealed record StreamCompleted(Exception? Error); - - public sealed record ConnectionCompleted(string ConnectionId, ConnectionCompletionReason Reason, long ConnectionTimestamp = 0, Activity? ConnectionActivity = null); - - public ConnectionActor(string connectionId) - { - _connectionId = connectionId; + public sealed record Drain; + private sealed record ConnectionCompleted; - Receive(OnMaterialize); - Receive(OnStreamCompleted); - Receive(OnGracefulStop); - Receive(_ => OnDrainTimeout()); - } - - private void OnMaterialize(Materialize msg) + private readonly ILoggingAdapter _log = Context.GetLogger(); + private SharedKillSwitch? _drainSwitch; + + public static Props Props( + int connectionId, + Flow connectionFlow, + IGraph, NotUsed> bridgeGraph, + IServerProtocolEngine engine, + TurboServerOptions options, + IServiceProvider? services = null) + => Akka.Actor.Props.Create(() => new ConnectionActor( + connectionId, connectionFlow, bridgeGraph, engine, options, services)); + + public ConnectionActor( + int connectionId, + Flow connectionFlow, + IGraph, NotUsed> bridgeGraph, + IServerProtocolEngine engine, + TurboServerOptions options, + IServiceProvider? services = null) { - _connectionTimestamp = msg.ConnectionTimestamp; - _connectionActivity = msg.ConnectionActivity; - _log.Debug("Connection {0} materializing pipeline", _connectionId); - - var negotiationStart = Stopwatch.GetTimestamp(); - - _killSwitch = KillSwitches.Shared("connection-" + _connectionId); + var materializer = Context.Materializer(); + _drainSwitch = KillSwitches.Shared(string.Concat("conn-", connectionId)); - var protocolBidi = msg.Engine.CreateFlow(msg.Services); - var composed = protocolBidi.Join(msg.BridgeFlow); - - if (Metrics.ProtocolNegotiationDuration().Enabled) - { - RecordProtocolNegotiation(negotiationStart, msg.Engine); - } + var protocolBidi = engine.CreateFlow(services); + var composed = protocolBidi.Join(Flow.FromGraph(bridgeGraph)); var self = Self; - Flow? loggingFlow = null; - if (msg.ConnectionLoggingCategory is { } loggingCategory) - { - var loggerFactory = msg.Services.GetRequiredService(); - var logger = loggerFactory.CreateLogger(loggingCategory); - if (logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) - { - loggingFlow = Flow.Create() - .Select(item => - { - if (item is TransportData { Buffer: var buffer }) - { - var dump = HexDumpFormatter.Format(buffer.Span); - logger.LogDebug("ReadAsync[{Length}]{NewLine}{Dump}", - buffer.Length, Environment.NewLine, dump); - } - - return item; - }); - } - } - - var pipeline = msg.ConnectionFlow - .Via(_killSwitch.Flow()); - - if (loggingFlow is not null) - { - pipeline = pipeline.Via(loggingFlow); - } - - var completionTask = pipeline + connectionFlow + .Via(_drainSwitch.Flow()) .ViaMaterialized( Flow.Create().WatchTermination(Keep.Right), Keep.Right) .Join(composed) - .Run(msg.Materializer); - - completionTask.PipeTo(self, - success: () => new StreamCompleted(null), - failure: ex => new StreamCompleted(ex)); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private void RecordProtocolNegotiation(long startTimestamp, IServerProtocolEngine engine) - { - var elapsed = Stopwatch.GetElapsedTime(startTimestamp); - var version = engine.ProtocolVersion; - Metrics.ProtocolNegotiationDuration().Record(elapsed.TotalSeconds, - new KeyValuePair("network.protocol.version", - TurboHttpInstrumentationExtensions.FormatProtocolVersion(version))); - } - - private void OnStreamCompleted(StreamCompleted msg) - { - var reason = _draining - ? ConnectionCompletionReason.ServerShutdown - : msg.Error is null - ? ConnectionCompletionReason.Normal - : ConnectionCompletionReason.Error; + .Run(materializer) + .PipeTo(self, success: _ => new ConnectionCompleted()); - if (msg.Error is not null) + Receive(_ => { - _log.Warning("Connection {0} stream failed: {1}", _connectionId, msg.Error.Message); - } - else - { - _log.Debug("Connection {0} stream completed normally", _connectionId); - } - - var completion = new ConnectionCompleted(_connectionId, reason, _connectionTimestamp, _connectionActivity); - Context.Parent.Tell(completion); - Self.Tell(PoisonPill.Instance); - } + _log.Debug("Connection {0}: draining", connectionId); + _drainSwitch?.Shutdown(); + }); - private void OnGracefulStop(GracefulStop msg) - { - _log.Info("Connection {0} graceful stop requested (timeout: {1})", _connectionId, msg.Timeout); - _draining = true; - _cts.Cancel(); - _killSwitch?.Shutdown(); - SetReceiveTimeout(msg.Timeout); - } - - private void OnDrainTimeout() - { - _log.Warning("Connection {0} drain timeout expired", _connectionId); - var completion = new ConnectionCompleted(_connectionId, ConnectionCompletionReason.Timeout); - Context.Parent.Tell(completion); - Self.Tell(PoisonPill.Instance); + Receive(_ => + { + _log.Debug("Connection {0}: completed", connectionId); + Context.Stop(Self); + }); } - - public static Props Create(string connectionId) - => Props.Create(() => new ConnectionActor(connectionId)); } diff --git a/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs b/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs index f9010928f..c6b6c0f33 100644 --- a/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs +++ b/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; using Akka; using Akka.Actor; using Akka.Event; @@ -7,294 +5,170 @@ using Akka.Streams.Dsl; using Microsoft.AspNetCore.Http.Features; using Servus.Akka.Transport; -using TurboHTTP.Diagnostics; using TurboHTTP.Server; -using static Servus.Core.Servus; namespace TurboHTTP.Streams.Lifecycle; internal sealed class ListenerActor : ReceiveActor { private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly IMaterializer _materializer = Context.Materializer(); private readonly IListenerFactory _factory; private readonly ListenerOptions _listenerOptions; private readonly TurboServerOptions _serverOptions; - private readonly Flow _bridgeFlow; - private readonly IServiceProvider _services; - private readonly IMaterializer _materializer; - private readonly string? _connectionLoggingCategory; - - private UniqueKillSwitch? _listenerKillSwitch; - private int _connectionCounter; - private readonly HashSet _activeConnections = []; - private readonly Dictionary _connectionMetrics = new(); - private bool _draining; + private readonly IGraph, NotUsed> _bridgeGraph; + private readonly IServerProtocolEngine _engine; public sealed record StartListening; + public sealed record DrainConnections; - public sealed record StopAccepting; - - public sealed record GracefulStop(TimeSpan Timeout); - - internal sealed record ConnectionStarted(string ConnectionId, IActorRef ConnectionActor); + internal sealed record ListeningStarted(int BoundPort, ListenerHandle Handle); - internal sealed record IncomingConnection(Flow ConnectionFlow); + private sealed record ConnectionArrived(Flow Connection); + private sealed record ListenerCompleted; + private sealed record ConnectionStopped; - internal sealed record ListeningStarted; - - private sealed record BindCompleted(IActorRef ReplyTo); - - internal sealed record ListenerStopped; - - internal sealed record ListenerFailed(Exception? Error); + private int _connectionIdCounter; + private int _activeConnections; + private bool _draining; + private TaskCompletionSource? _completionTcs; public ListenerActor( IListenerFactory factory, ListenerOptions listenerOptions, TurboServerOptions serverOptions, - Flow bridgeFlow, - IServiceProvider services, - IMaterializer materializer, - string? connectionLoggingCategory = null) + IGraph, NotUsed> bridgeGraph, + IServerProtocolEngine engine) { _factory = factory; _listenerOptions = listenerOptions; _serverOptions = serverOptions; - _bridgeFlow = bridgeFlow; - _services = services; - _materializer = materializer; - _connectionLoggingCategory = connectionLoggingCategory; + _bridgeGraph = bridgeGraph; + _engine = engine; Receive(_ => OnStartListening()); - Receive(OnBindCompleted); - Receive(OnIncomingConnection); - Receive(_ => OnStopAccepting()); - Receive(OnGracefulStop); - Receive(OnConnectionCompleted); - Receive(_ => - _log.Info("Listener on {0}:{1} stopped", _listenerOptions.Host, _listenerOptions.Port)); - Receive(OnListenerFailed); - Receive(OnChildTerminated); + Receive(OnConnectionArrived); + Receive(_ => OnDrainConnections()); + Receive(_ => OnConnectionStopped()); + Receive(_ => OnListenerCompleted()); } private void OnStartListening() { _log.Info("Listener starting on {0}:{1}", _listenerOptions.Host, _listenerOptions.Port); + _completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var listenerSource = _factory.Bind(_listenerOptions); var self = Self; var sender = Sender; - var ((boundTask, killSwitch), completionTask) = listenerSource - .Select(flow => new IncomingConnection(flow)) - .ViaMaterialized(KillSwitches.Single(), Keep.Both) - .ToMaterialized( - Sink.ForEach(msg => self.Tell(msg)), + var (boundTask, acceptSwitch) = listenerSource + .ViaMaterialized( + KillSwitches.Single>(), Keep.Both) + .To(Sink.ForEach>( + connectionFlow => self.Tell(new ConnectionArrived(connectionFlow)))) .Run(_materializer); - _listenerKillSwitch = killSwitch; + var handle = new ListenerHandle(acceptSwitch, _completionTcs.Task); - boundTask.PipeTo(Self, - success: () => new BindCompleted(sender), - failure: ex => new ListenerFailed(ex)); - - completionTask.PipeTo(Self, - success: () => new ListenerStopped(), - failure: ex => new ListenerFailed(ex)); - } - - private void OnBindCompleted(BindCompleted msg) - { - msg.ReplyTo.Tell(new ListeningStarted()); + boundTask.PipeTo(sender, + success: port => new ListeningStarted(port, handle), + failure: ex => + { + _log.Error(ex, "Failed to bind on {0}:{1}", _listenerOptions.Host, _listenerOptions.Port); + throw ex; + }); } - private void OnIncomingConnection(IncomingConnection msg) + private void OnConnectionArrived(ConnectionArrived msg) { var limit = _serverOptions.Limits.MaxConcurrentConnections; - if (limit > 0 && _activeConnections.Count >= limit) + if (limit > 0 && _activeConnections >= limit) { - _log.Warning("Connection rejected: limit {0} reached ({1} active)", - limit, _activeConnections.Count); - if (Metrics.RejectedConnections().Enabled) - { - RecordRejectedConnection(); - } - RejectConnection(msg.ConnectionFlow); + RejectConnection(msg.Connection); return; } - var connectionId = string.Concat("conn-", ++_connectionCounter); - var engine = ResolveEngineForListener(); + var connectionId = ++_connectionIdCounter; + _activeConnections++; - long timestamp = 0; - Activity? connectionActivity = null; + var child = Context.ActorOf( + ConnectionActor.Props(connectionId, msg.Connection, _bridgeGraph, _engine, _serverOptions), + string.Concat("conn-", connectionId)); - if (Metrics.ActiveConnections().Enabled || Tracing.IsServerTracingActive()) - { - OnIncomingConnectionInstrumented(out timestamp, out connectionActivity); - } - - var child = Context.ActorOf(ConnectionActor.Create(connectionId), connectionId); - Context.Watch(child); - _activeConnections.Add(child); - _connectionMetrics[child] = (timestamp, connectionActivity); - - child.Tell(new ConnectionActor.Materialize( - msg.ConnectionFlow, - engine, - _bridgeFlow, - _services, - _materializer, - _connectionLoggingCategory, - timestamp, - connectionActivity)); - - Context.Parent.Tell(new ConnectionStarted(connectionId, child)); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private void OnIncomingConnectionInstrumented(out long timestamp, out Activity? connectionActivity) - { - timestamp = Stopwatch.GetTimestamp(); - var host = _listenerOptions.Host ?? "localhost"; - var port = _listenerOptions.Port; - var transport = _listenerOptions is QuicListenerOptions ? "udp" : "tcp"; - - var tags = new TagList(); - TurboServerInstrumentationExtensions.InjectConnectionTags(ref tags, host, port); - tags.Add("network.transport", transport); - Metrics.ActiveConnections().Add(1, in tags); - - connectionActivity = Tracing.StartConnectionActivity(host, port, transport); + Context.WatchWith(child, new ConnectionStopped()); } - [MethodImpl(MethodImplOptions.NoInlining)] - private void RecordRejectedConnection() + private void OnDrainConnections() { - var host = _listenerOptions.Host ?? "localhost"; - Metrics.RejectedConnections().Add(1, - new KeyValuePair("server.address", host), - new KeyValuePair("server.port", _listenerOptions.Port)); - } - - private void OnStopAccepting() - { - _log.Info("Listener stopping accept loop"); - _listenerKillSwitch?.Shutdown(); - } - - private void OnGracefulStop(GracefulStop msg) - { - OnStopAccepting(); - + _log.Info("Listener draining {0} active connection(s)", _activeConnections); _draining = true; - if (Metrics.DrainActive().Enabled) - { - Metrics.DrainActive().Add(1); - } - foreach (var child in _activeConnections) + foreach (var child in Context.GetChildren()) { - child.Tell(new ConnectionActor.GracefulStop(msg.Timeout)); + child.Tell(new ConnectionActor.Drain()); } + + TryComplete(); } - private void OnConnectionCompleted(ConnectionActor.ConnectionCompleted msg) + private void OnConnectionStopped() { - Context.Parent.Tell(msg); + _activeConnections--; + TryComplete(); } - private void OnListenerFailed(ListenerFailed msg) + private void OnListenerCompleted() { - if (msg.Error is not null) - { - _log.Error(msg.Error, "Listener on {0}:{1} failed", _listenerOptions.Host, _listenerOptions.Port); - } + _log.Debug("Listener source completed"); + TryComplete(); } - private void OnChildTerminated(Terminated msg) + private void TryComplete() { - _activeConnections.Remove(msg.ActorRef); - - if (_connectionMetrics.Remove(msg.ActorRef, out var metrics)) + if (_draining && _activeConnections <= 0) { - if (Metrics.ActiveConnections().Enabled || Metrics.ConnectionDuration().Enabled || metrics.Activity is not null) - { - OnConnectionEndInstrumented(metrics.Timestamp, metrics.Activity); - } - } - - if (_draining && _activeConnections.Count == 0) - { - if (Metrics.DrainActive().Enabled) - { - Metrics.DrainActive().Add(-1); - } - _draining = false; + _completionTcs?.TrySetResult(Done.Instance); } } - [MethodImpl(MethodImplOptions.NoInlining)] - private void OnConnectionEndInstrumented(long timestamp, Activity? connectionActivity) + private void RejectConnection(Flow connectionFlow) { - var host = _listenerOptions.Host ?? "localhost"; - var port = _listenerOptions.Port; - var transport = _listenerOptions is QuicListenerOptions ? "udp" : "tcp"; - - var tags = new TagList(); - TurboServerInstrumentationExtensions.InjectConnectionTags(ref tags, host, port); - tags.Add("network.transport", transport); - - if (Metrics.ActiveConnections().Enabled) + try { - Metrics.ActiveConnections().Add(-1, in tags); - } + var killSwitch = KillSwitches.Shared(string.Concat("reject-", Guid.NewGuid())); - if (Metrics.ConnectionDuration().Enabled && timestamp > 0) - { - var elapsed = Stopwatch.GetElapsedTime(timestamp); - Metrics.ConnectionDuration().Record(elapsed.TotalSeconds, in tags); - } + Source.Empty() + .Via(connectionFlow) + .Via(killSwitch.Flow()) + .RunWith( + Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), + _materializer); - if (connectionActivity is not null) + killSwitch.Shutdown(); + } + catch (Exception ex) { - Tracing.StopConnectionActivity(connectionActivity, null); + _log.Warning("Error rejecting connection: {0}", ex.Message); } } - private void RejectConnection(Flow connectionFlow) - { - var killSwitch = KillSwitches.Shared("reject"); - - Source.Empty() - .Via(connectionFlow) - .Via(killSwitch.Flow()) - .RunWith(Sink.Ignore().MapMaterializedValue(_ => NotUsed.Instance), _materializer); - - killSwitch.Shutdown(); - } - - private IServerProtocolEngine ResolveEngineForListener() + protected override SupervisorStrategy SupervisorStrategy() { - if (_listenerOptions is QuicListenerOptions) + return new OneForOneStrategy(ex => { - return ProtocolRouter.ResolveEngine(new Version(3, 0), _serverOptions); - } - - return ProtocolRouter.ResolveNegotiating(_serverOptions); + _log.Warning("ConnectionActor failed: {0}", ex.Message); + return Directive.Stop; + }); } public static Props Create( IListenerFactory factory, ListenerOptions listenerOptions, TurboServerOptions serverOptions, - Flow bridgeFlow, - IServiceProvider services, - IMaterializer materializer, - string? connectionLoggingCategory = null) + IGraph, NotUsed> bridgeGraph, + IServerProtocolEngine engine) => Props.Create(() => new ListenerActor( - factory, listenerOptions, serverOptions, - bridgeFlow, services, materializer, - connectionLoggingCategory)); -} \ No newline at end of file + factory, listenerOptions, serverOptions, bridgeGraph, engine)); +} diff --git a/src/TurboHTTP/Streams/Lifecycle/ListenerHandle.cs b/src/TurboHTTP/Streams/Lifecycle/ListenerHandle.cs new file mode 100644 index 000000000..43f67edf7 --- /dev/null +++ b/src/TurboHTTP/Streams/Lifecycle/ListenerHandle.cs @@ -0,0 +1,8 @@ +using Akka; +using Akka.Streams; + +namespace TurboHTTP.Streams.Lifecycle; + +internal sealed record ListenerHandle( + UniqueKillSwitch AcceptSwitch, + Task CompletionTask); diff --git a/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs b/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs index 17d92dbf2..4b8388866 100644 --- a/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs +++ b/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs @@ -1,61 +1,85 @@ +using Akka; using Akka.Actor; using Akka.Event; +using Akka.Streams; +using Microsoft.AspNetCore.Http.Features; +using Servus.Akka.Transport; +using TurboHTTP.Server; namespace TurboHTTP.Streams.Lifecycle; internal sealed class ServerSupervisorActor : ReceiveActor { private readonly ILoggingAdapter _log = Context.GetLogger(); - private readonly Dictionary _activeConnections = new(); - private readonly List _listeners = []; + private readonly List _handles = []; + private readonly List _listenerActors = []; + private readonly List _boundPorts = []; private IActorRef _startRequester = ActorRefs.Nobody; private int _pendingListenerCount; + private IActorRef _drainRequester = ActorRefs.Nobody; - public sealed record StartListeners(IReadOnlyList ListenerProps); - public sealed record ListenersReady; + public sealed record StartServer( + IGraph, NotUsed> BridgeFlow, + TurboServerOptions Options, + IReadOnlyList Bindings); + + public sealed record ListenersReady(IReadOnlyList BoundPorts); public sealed record StopAccepting; public sealed record BeginDrain(TimeSpan Timeout); public sealed record DrainComplete; - public sealed record GetConnectionCount; public ServerSupervisorActor() { - Receive(OnStartListeners); - Receive(_ => OnListenerReady()); + Receive(OnStartServer); + Receive(OnListenerReady); Receive(_ => OnStopAccepting()); Receive(OnBeginDrain); - Receive(OnConnectionStarted); - Receive(OnConnectionCompleted); - Receive(_ => Sender.Tell(_activeConnections.Count)); + Receive(OnDrainComplete); } - private void OnStartListeners(StartListeners msg) + private void OnStartServer(StartServer msg) { _startRequester = Sender; - _pendingListenerCount = msg.ListenerProps.Count; + + _pendingListenerCount = msg.Bindings.Count; if (_pendingListenerCount == 0) { - _startRequester.Tell(new ListenersReady()); + _startRequester.Tell(new ListenersReady([])); return; } - for (var i = 0; i < msg.ListenerProps.Count; i++) + for (var i = 0; i < msg.Bindings.Count; i++) { + var binding = msg.Bindings[i]; + var engine = binding.Options is QuicListenerOptions + ? ProtocolRouter.ResolveEngine(new Version(3, 0), msg.Options) + : ProtocolRouter.ResolveNegotiating(msg.Options); + + var props = ListenerActor.Create( + binding.Factory, + binding.Options, + msg.Options, + msg.BridgeFlow, + engine); + var name = string.Concat("listener-", i); - var listener = Context.ActorOf(msg.ListenerProps[i], name); + var listener = Context.ActorOf(props, name); + _listenerActors.Add(listener); listener.Tell(new ListenerActor.StartListening()); - _listeners.Add(listener); } } - private void OnListenerReady() + private void OnListenerReady(ListenerActor.ListeningStarted msg) { + _boundPorts.Add(msg.BoundPort); + _handles.Add(msg.Handle); _pendingListenerCount--; + if (_pendingListenerCount <= 0) { - _log.Info("All {0} listener(s) ready", _listeners.Count); - _startRequester.Tell(new ListenersReady()); + _log.Info("All {0} listener(s) ready", _handles.Count); + _startRequester.Tell(new ListenersReady(_boundPorts)); _startRequester = ActorRefs.Nobody; } } @@ -63,36 +87,50 @@ private void OnListenerReady() private void OnStopAccepting() { _log.Info("Supervisor: stop accepting on all listeners"); - foreach (var listener in _listeners) + foreach (var handle in _handles) { - listener.Tell(new ListenerActor.StopAccepting()); + handle.AcceptSwitch.Shutdown(); } } private void OnBeginDrain(BeginDrain msg) { - _log.Info("Supervisor: draining {0} connections (timeout: {1})", _activeConnections.Count, msg.Timeout); - foreach (var listener in _listeners) + _log.Info("Supervisor: initiating graceful drain (timeout: {0})", msg.Timeout); + _drainRequester = Sender; + + if (_handles.Count == 0) { - listener.Tell(new ListenerActor.GracefulStop(msg.Timeout)); + Sender.Tell(new DrainComplete()); + _drainRequester = ActorRefs.Nobody; + return; } - if (_activeConnections.Count == 0) + var self = Self; + var completionTasks = new List(_handles.Count); + + foreach (var listenerActor in _listenerActors) { - Sender.Tell(new DrainComplete()); + listenerActor.Tell(new ListenerActor.DrainConnections()); } - } - private void OnConnectionStarted(ListenerActor.ConnectionStarted msg) - { - _activeConnections[msg.ConnectionId] = msg.ConnectionActor; - _log.Debug("Connection {0} started, active={1}", msg.ConnectionId, _activeConnections.Count); + foreach (var handle in _handles) + { + completionTasks.Add(handle.CompletionTask); + } + + Task.WhenAny( + Task.WhenAll(completionTasks), + Task.Delay(msg.Timeout)) + .PipeTo(self, + success: _ => new DrainComplete(), + failure: ex => new DrainComplete()); } - private void OnConnectionCompleted(ConnectionActor.ConnectionCompleted msg) + private void OnDrainComplete(DrainComplete msg) { - _activeConnections.Remove(msg.ConnectionId); - _log.Debug("Connection {0} completed ({1}), active={2}", msg.ConnectionId, msg.Reason, _activeConnections.Count); + _log.Info("Supervisor: drain completed"); + _drainRequester.Tell(new DrainComplete()); + _drainRequester = ActorRefs.Nobody; } protected override SupervisorStrategy SupervisorStrategy() diff --git a/src/TurboHTTP/Streams/Lifecycle/StreamOwner.cs b/src/TurboHTTP/Streams/Lifecycle/StreamOwner.cs index a2d98af27..57e87ac98 100644 --- a/src/TurboHTTP/Streams/Lifecycle/StreamOwner.cs +++ b/src/TurboHTTP/Streams/Lifecycle/StreamOwner.cs @@ -9,7 +9,7 @@ using Servus.Akka.Transport; using TurboHTTP.Internal; using TurboHTTP.Streams.Pooling; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Lifecycle; diff --git a/src/TurboHTTP/Streams/NegotiatingServerEngine.cs b/src/TurboHTTP/Streams/NegotiatingServerEngine.cs index 22aa17b38..db5bd0347 100644 --- a/src/TurboHTTP/Streams/NegotiatingServerEngine.cs +++ b/src/TurboHTTP/Streams/NegotiatingServerEngine.cs @@ -8,22 +8,15 @@ namespace TurboHTTP.Streams; -internal sealed class NegotiatingServerEngine : IServerProtocolEngine +internal sealed class NegotiatingServerEngine(TurboServerOptions options) : IServerProtocolEngine { - private readonly TurboServerOptions _options; - - public NegotiatingServerEngine(TurboServerOptions options) - { - _options = options; - } - public Version ProtocolVersion => new(1, 1); public BidiFlow CreateFlow(IServiceProvider? services = null) { return BidiFlow.FromGraph(GraphDsl.Create(b => { - var connection = b.Add(new ProtocolNegotiatorConnectionStage(_options, services)); + var connection = b.Add(new ProtocolNegotiatorConnectionStage(options, services)); return new BidiShape< ITransportInbound, diff --git a/src/TurboHTTP/Streams/PipelineDescriptor.cs b/src/TurboHTTP/Streams/PipelineDescriptor.cs index 4dea91f31..0dc8fc573 100644 --- a/src/TurboHTTP/Streams/PipelineDescriptor.cs +++ b/src/TurboHTTP/Streams/PipelineDescriptor.cs @@ -16,7 +16,9 @@ internal sealed record PipelineDescriptor( CachePolicy? CachePolicy, IReadOnlyList Handlers, bool AutomaticDecompression = true, - AltSvcCache? AltSvcCache = null) + AltSvcCache? AltSvcCache = null, + bool UseProxy = true, + System.Net.IWebProxy? Proxy = null) { public static readonly PipelineDescriptor Empty = new( RedirectPolicy: null, diff --git a/src/TurboHTTP/Streams/ProtocolCoreBuilder.cs b/src/TurboHTTP/Streams/ProtocolCoreBuilder.cs index 743978b42..f17d33130 100644 --- a/src/TurboHTTP/Streams/ProtocolCoreBuilder.cs +++ b/src/TurboHTTP/Streams/ProtocolCoreBuilder.cs @@ -37,7 +37,7 @@ internal static Flow Build( var core = (Flow) Flow.Create() - .GroupByRequestEndpoint(RequestEndpoint.FromRequest, maxSubstreams: clientOptions.MaxEndpointSubstreams, + .GroupByRequestEndpoint(RequestEndpoint.FromRequest, maxSubstreams: clientOptions.MaxConcurrentEndpoints, maxSubstreamsPerKey: MaxSubstreamsPerKey, maxConcurrencyPerSlot: MaxConcurrencyPerSlot) .ViaSubFlow(endpointDispatch) diff --git a/src/TurboHTTP/Streams/Stages/Client/ClientConnectionShape.cs b/src/TurboHTTP/Streams/Stages/Client/ClientConnectionShape.cs index 02d2839ce..8ebbae48d 100644 --- a/src/TurboHTTP/Streams/Stages/Client/ClientConnectionShape.cs +++ b/src/TurboHTTP/Streams/Stages/Client/ClientConnectionShape.cs @@ -4,24 +4,17 @@ namespace TurboHTTP.Streams.Stages.Client; -internal sealed class ClientConnectionShape : Shape +internal sealed class ClientConnectionShape( + Inlet inNetwork, + Outlet outResponse, + Inlet inRequest, + Outlet outNetwork) + : Shape { - public Inlet InNetwork { get; } - public Outlet OutResponse { get; } - public Inlet InRequest { get; } - public Outlet OutNetwork { get; } - - public ClientConnectionShape( - Inlet inNetwork, - Outlet outResponse, - Inlet inRequest, - Outlet outNetwork) - { - InNetwork = inNetwork; - OutResponse = outResponse; - InRequest = inRequest; - OutNetwork = outNetwork; - } + public Inlet InNetwork { get; } = inNetwork; + public Outlet OutResponse { get; } = outResponse; + public Inlet InRequest { get; } = inRequest; + public Outlet OutNetwork { get; } = outNetwork; public override ImmutableArray Inlets => [InNetwork, InRequest]; diff --git a/src/TurboHTTP/Streams/Stages/Client/HandlerBidiStage.cs b/src/TurboHTTP/Streams/Stages/Client/HandlerBidiStage.cs index 1a2b4fc39..cc3651a18 100644 --- a/src/TurboHTTP/Streams/Stages/Client/HandlerBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Client/HandlerBidiStage.cs @@ -1,8 +1,9 @@ using TurboHTTP.Client; +using TurboHTTP.Protocol; using Akka.Event; using Akka.Streams; using Akka.Streams.Stage; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Client; @@ -58,7 +59,12 @@ public Logic(HandlerBidiStage stage) : base(stage.Shape) catch (Exception ex) { Tracing.For("Handler").Warning(this, "→ ProcessRequest threw: {0}", ex.Message); - Push(stage._outRequest, request); + // Fail only the offending request — keep the shared pipeline alive for other in-flight requests. + request.Fail(ex); + if (!IsClosed(stage._inRequest)) + { + Pull(stage._inRequest); + } } }, onUpstreamFinish: () => Complete(stage._outRequest), @@ -83,7 +89,12 @@ public Logic(HandlerBidiStage stage) : base(stage.Shape) catch (Exception ex) { Tracing.For("Handler").Warning(this, "← ProcessResponse threw: {0}", ex.Message); - Push(stage._outResponse, resp); + // Fail only the request this response belongs to — keep the shared pipeline alive. + resp.RequestMessage?.Fail(ex); + if (!IsClosed(stage._inResponse)) + { + Pull(stage._inResponse); + } } }, onUpstreamFinish: () => Complete(stage._outResponse), diff --git a/src/TurboHTTP/Streams/Stages/Client/Http10ClientConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Client/Http10ClientConnectionStage.cs index 30ba75723..42cdee50c 100644 --- a/src/TurboHTTP/Streams/Stages/Client/Http10ClientConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Client/Http10ClientConnectionStage.cs @@ -6,25 +6,18 @@ namespace TurboHTTP.Streams.Stages.Client; -internal sealed class Http10ClientConnectionStage : GraphStage +internal sealed class Http10ClientConnectionStage(TurboClientOptions options) : GraphStage { private readonly Inlet _inServer = new("Http10Connection.In.Network"); private readonly Outlet _outResponse = new("Http10Connection.Out.Response"); private readonly Inlet _inApp = new("Http10Connection.In.Request"); private readonly Outlet _outNetwork = new("Http10Connection.Out.Network"); - private readonly TurboClientOptions _options; - - public Http10ClientConnectionStage(TurboClientOptions options) - { - _options = options; - } - public override ClientConnectionShape Shape => new(_inServer, _outResponse, _inApp, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) { return new HttpConnectionStageLogic( - this, ops => new Http10ClientStateMachine(ops, _options)); + this, ops => new Http10ClientStateMachine(ops, options)); } } \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Client/Http11ClientConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Client/Http11ClientConnectionStage.cs index ed5daf03e..f9ae7c450 100644 --- a/src/TurboHTTP/Streams/Stages/Client/Http11ClientConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Client/Http11ClientConnectionStage.cs @@ -6,25 +6,18 @@ namespace TurboHTTP.Streams.Stages.Client; -internal sealed class Http11ClientConnectionStage : GraphStage +internal sealed class Http11ClientConnectionStage(TurboClientOptions options) : GraphStage { private readonly Inlet _inServer = new("Http11Connection.In.Network"); private readonly Outlet _outResponse = new("Http11Connection.Out.Response"); private readonly Inlet _inApp = new("Http11Connection.In.Request"); private readonly Outlet _outNetwork = new("Http11Connection.Out.Network"); - private readonly TurboClientOptions _options; - - public Http11ClientConnectionStage(TurboClientOptions options) - { - _options = options; - } - public override ClientConnectionShape Shape => new(_inServer, _outResponse, _inApp, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) { return new HttpConnectionStageLogic( - this, ops => new Http11ClientStateMachine(ops, _options)); + this, ops => new Http11ClientStateMachine(ops, options)); } } \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Client/Http20ClientConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Client/Http20ClientConnectionStage.cs index c5a1fc2ad..32e474c47 100644 --- a/src/TurboHTTP/Streams/Stages/Client/Http20ClientConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Client/Http20ClientConnectionStage.cs @@ -6,23 +6,17 @@ namespace TurboHTTP.Streams.Stages.Client; -internal sealed class Http20ClientConnectionStage : GraphStage +internal sealed class Http20ClientConnectionStage(TurboClientOptions options) : GraphStage { private readonly Inlet _inNetwork = new("Http20Connection.In.Network"); private readonly Outlet _outResponse = new("Http20Connection.Out.Response"); private readonly Inlet _inRequest = new("Http20Connection.In.Request"); private readonly Outlet _outNetwork = new("Http20Connection.Out.Network"); - private readonly TurboClientOptions _options; public override ClientConnectionShape Shape => new(_inNetwork, _outResponse, _inRequest, _outNetwork); - public Http20ClientConnectionStage(TurboClientOptions options) - { - _options = options; - } - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new HttpConnectionStageLogic( this, - ops => new Http2ClientStateMachine(_options, ops)); + ops => new Http2ClientStateMachine(options, ops)); } diff --git a/src/TurboHTTP/Streams/Stages/Client/Http30ClientConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Client/Http30ClientConnectionStage.cs index a51fe0e1e..1b094a95f 100644 --- a/src/TurboHTTP/Streams/Stages/Client/Http30ClientConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Client/Http30ClientConnectionStage.cs @@ -6,24 +6,17 @@ namespace TurboHTTP.Streams.Stages.Client; -internal sealed class Http30ClientConnectionStage : GraphStage +internal sealed class Http30ClientConnectionStage(TurboClientOptions options) : GraphStage { private readonly Inlet _inServer = new("Http30Connection.In.Network"); private readonly Outlet _outResponse = new("Http30Connection.Out.Response"); private readonly Inlet _inApp = new("Http30Connection.In.Request"); private readonly Outlet _outNetwork = new("Http30Connection.Out.Network"); - private readonly TurboClientOptions _options; - - public Http30ClientConnectionStage(TurboClientOptions options) - { - _options = options; - } - public override ClientConnectionShape Shape => new(_inServer, _outResponse, _inApp, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new HttpConnectionStageLogic( this, - ops => new Http3ClientStateMachine(_options, ops)); + ops => new Http3ClientStateMachine(options, ops)); } \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Client/HttpConnectionStageLogic.cs b/src/TurboHTTP/Streams/Stages/Client/HttpConnectionStageLogic.cs index fd3ac3d67..8d5291817 100644 --- a/src/TurboHTTP/Streams/Stages/Client/HttpConnectionStageLogic.cs +++ b/src/TurboHTTP/Streams/Stages/Client/HttpConnectionStageLogic.cs @@ -3,14 +3,17 @@ using Akka.Streams; using Akka.Streams.Stage; using Servus.Akka.Transport; +using TurboHTTP.Client; using TurboHTTP.Protocol; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Client; internal sealed class HttpConnectionStageLogic : TimerGraphStageLogic, IClientStageOperations where TSM : IClientStateMachine { + private const string TraceCategory = "Stage"; + private readonly Inlet _inServer; private readonly Outlet _outResponse; private readonly Inlet _inApp; @@ -19,7 +22,9 @@ internal sealed class HttpConnectionStageLogic : TimerGraphStageLogic, ICli private readonly TSM _sm; private readonly Queue _outboundQueue = new(64); private readonly Queue _responseQueue = new(64); + private readonly Dictionary _ctRegistrations = new(); private IActorRef _stageActor = ActorRefs.Nobody; + private Action? _cancelCallback; public HttpConnectionStageLogic( GraphStage stage, @@ -36,13 +41,13 @@ public HttpConnectionStageLogic( SetHandler(_inServer, onPush: OnServerPush, onUpstreamFinish: () => { - Tracing.For("Stage").Debug(this, "server upstream finished"); + Tracing.For(TraceCategory).Debug(this, "server upstream finished"); _sm.OnUpstreamFinished(); CompleteStage(); }, onUpstreamFailure: ex => { - Tracing.For("Stage").Info(this, "server upstream failure: {0}", ex.Message); + Tracing.For(TraceCategory).Info(this, "server upstream failure: {0}", ex.Message); _sm.OnUpstreamFinished(); CompleteStage(); }); @@ -55,39 +60,51 @@ public HttpConnectionStageLogic( return; } - if (!HasBeenPulled(_inServer) && !IsClosed(_inServer)) + if (!_sm.ShouldPauseNetwork && !HasBeenPulled(_inServer) && !IsClosed(_inServer)) { + Tracing.For(TraceCategory).Debug(this, "response outlet pull → pulling _inServer"); Pull(_inServer); } }); SetHandler(_inApp, onPush: () => - { - var request = Grab(_inApp); - try { - _sm.OnRequest(request); - } - catch (Exception ex) - { - Tracing.For("Stage").Error(this, "OnRequest threw: {0}", ex.Message); - request.Fail(ex); - } - - TryPullRequest(); - }, - onUpstreamFinish: () => - { - Tracing.For("Stage").Debug(this, "request upstream finished (inFlight={0}, reconnecting={1})", _sm.HasInFlightRequests, _sm.IsReconnecting); - if (!_sm.HasInFlightRequests && !_sm.IsReconnecting) + var request = Grab(_inApp); + try + { + _sm.OnRequest(request); + + var ct = request.GetCancellationToken(); + if (ct.CanBeCanceled) + { + var reg = ct.UnsafeRegister( + static (state, _) => + { + var (cb, req) = ((Action, HttpRequestMessage))state!; + cb(req); + }, + (_cancelCallback!, request)); + _ctRegistrations[request] = reg; + } + } + catch (Exception ex) + { + Tracing.For(TraceCategory).Error(this, "OnRequest threw: {0}", ex.Message); + request.Fail(ex); + } + + TryPullRequest(); + }, + onUpstreamFinish: () => { - CompleteStage(); - } - }, - onUpstreamFailure: _ => - { - _sm.OnUpstreamFinished(); - }); + Tracing.For(TraceCategory).Debug(this, "request upstream finished (inFlight={0}, reconnecting={1})", + _sm.HasInFlightRequests, _sm.IsReconnecting); + if (!_sm.HasInFlightRequests && !_sm.IsReconnecting) + { + CompleteStage(); + } + }, + onUpstreamFailure: _ => { _sm.OnUpstreamFinished(); }); SetHandler(_outNetwork, onPull: OnNetworkPull); } @@ -95,18 +112,35 @@ public HttpConnectionStageLogic( public override void PreStart() { _stageActor = GetStageActor(OnStageActorMessage).Ref; + _cancelCallback = GetAsyncCallback(OnRequestCancelled); _sm.PreStart(); } private void OnStageActorMessage((IActorRef sender, object message) args) { + Tracing.For(TraceCategory).Debug(this, "actor msg: {0}, pause={1}", args.message.GetType().Name, + _sm.ShouldPauseNetwork); _sm.OnBodyMessage(args.message); + + var pauseAfter = _sm.ShouldPauseNetwork; + var pulled = HasBeenPulled(_inServer); + var closed = IsClosed(_inServer); + Tracing.For(TraceCategory) + .Debug(this, "after msg: pause={0}, pulled={1}, closed={2}", pauseAfter, pulled, closed); + + if (!pauseAfter && !pulled && !closed) + { + Tracing.For(TraceCategory).Debug(this, "re-pull _inServer after body message"); + Pull(_inServer); + } + TryPullRequest(); TryCompleteAfterAllResponses(); } private void OnServerPush() { + Tracing.For(TraceCategory).Debug(this, "server push"); var item = Grab(_inServer); try { @@ -114,7 +148,7 @@ private void OnServerPush() } catch (Exception ex) { - Tracing.For("Stage").Warning(this, "DecodeServerData threw: {0}", ex.Message); + Tracing.For(TraceCategory).Warning(this, "DecodeServerData threw: {0}", ex.Message); } if (_responseQueue.Count > 0) @@ -122,7 +156,7 @@ private void OnServerPush() TryPushResponse(); } - if (!HasBeenPulled(_inServer) && !IsClosed(_inServer)) + if (!_sm.ShouldPauseNetwork && !HasBeenPulled(_inServer) && !IsClosed(_inServer)) { Pull(_inServer); } @@ -136,6 +170,7 @@ private void OnNetworkPull() if (_outboundQueue.Count > 0) { Push(_outNetwork, _outboundQueue.Dequeue()); + _sm.OnOutboundFlushed(); TryCompleteAfterAllResponses(); return; } @@ -158,25 +193,30 @@ protected override void OnTimer(object timerKey) && _responseQueue.Count == 0 && _outboundQueue.Count == 0) { + Tracing.For(TraceCategory).Debug(this, "drain complete — closing stage"); CompleteStage(); } return; } + Tracing.For(TraceCategory).Trace(this, "timer fired: {0}", name); _sm.OnTimerFired(name); } - // --- IClientStageOperations implementation --- - void IClientStageOperations.OnResponse(HttpResponseMessage response) { - Tracing.For("Protocol").Debug(this, "← {0}", (int)response.StatusCode); + if (response.RequestMessage is not null && _ctRegistrations.Remove(response.RequestMessage, out var reg)) + { + reg.Dispose(); + } + if (IsAvailable(_outResponse)) { Push(_outResponse, response); return; } + _responseQueue.Enqueue(response); } @@ -185,26 +225,27 @@ void IClientStageOperations.OnOutbound(ITransportOutbound item) if (IsAvailable(_outNetwork)) { Push(_outNetwork, item); + _sm.OnOutboundFlushed(); return; } + _outboundQueue.Enqueue(item); } - void IClientStageOperations.OnScheduleTimer(string name, TimeSpan duration) - { - ScheduleOnce(name, duration); - } + void IClientStageOperations.OnScheduleTimer(string name, TimeSpan duration) => ScheduleOnce(name, duration); - void IClientStageOperations.OnCancelTimer(string name) - { - CancelTimer(name); - } - - ILoggingAdapter IClientStageOperations.Log => Log; + void IClientStageOperations.OnCancelTimer(string name) => CancelTimer(name); IActorRef IClientStageOperations.StageActor => _stageActor; - // --- Mechanical helpers --- + private void OnRequestCancelled(HttpRequestMessage request) + { + if (_ctRegistrations.Remove(request, out var reg)) + { + reg.Dispose(); + } + _sm.OnRequestCancelled(request); + } private void TryPushResponse() { @@ -266,16 +307,17 @@ private bool TryCoalesceOutbound() for (var i = 0; i < coalesceCount; i++) { var item = _outboundQueue.Dequeue(); - if (item is TransportData { Buffer: var buf }) + if (item is TransportData td) { - buf.Span.CopyTo(dest[offset..]); - offset += buf.Length; - buf.Dispose(); + td.Buffer.Span.CopyTo(dest[offset..]); + offset += td.Buffer.Length; + td.Buffer.Dispose(); + td.Return(); } } merged.Length = offset; - Push(_outNetwork, new TransportData(merged)); + Push(_outNetwork, TransportData.Rent(merged)); return true; } @@ -306,12 +348,20 @@ private void TryCompleteAfterAllResponses() public override void PostStop() { - Tracing.For("Stage").Debug(this, "PostStop: draining {0} outbound, {1} responses", _outboundQueue.Count, _responseQueue.Count); + foreach (var reg in _ctRegistrations.Values) + { + reg.Dispose(); + } + _ctRegistrations.Clear(); + + Tracing.For(TraceCategory).Debug(this, "PostStop: draining {0} outbound, {1} responses", + _outboundQueue.Count, _responseQueue.Count); while (_outboundQueue.Count > 0) { - if (_outboundQueue.Dequeue() is TransportData { Buffer: var buffer }) + if (_outboundQueue.Dequeue() is TransportData td) { - buffer.Dispose(); + td.Buffer.Dispose(); + td.Return(); } } @@ -322,4 +372,4 @@ public override void PostStop() _sm.Cleanup(); } -} +} \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Client/IClientStageOperations.cs b/src/TurboHTTP/Streams/Stages/Client/IClientStageOperations.cs index 12ef4bedc..60145fc27 100644 --- a/src/TurboHTTP/Streams/Stages/Client/IClientStageOperations.cs +++ b/src/TurboHTTP/Streams/Stages/Client/IClientStageOperations.cs @@ -10,6 +10,5 @@ internal interface IClientStageOperations void OnOutbound(ITransportOutbound item); void OnScheduleTimer(string name, TimeSpan duration); void OnCancelTimer(string name); - ILoggingAdapter Log { get; } IActorRef StageActor { get; } } \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs b/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs index c23b43ff8..bd10b0f5d 100644 --- a/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs +++ b/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs @@ -1,6 +1,8 @@ using System.Net; using System.Net.Http.Headers; using TurboHTTP.Client; +using TurboHTTP.Internal; +using TurboHTTP.Protocol; using TurboHTTP.Protocol.Semantics; namespace TurboHTTP.Streams.Stages.Client; @@ -8,20 +10,14 @@ namespace TurboHTTP.Streams.Stages.Client; /// /// Stateless request enrichment logic extracted from the former . /// Applied as a Select() transform in the pipeline — no separate GraphStage needed. -/// Handles: URI resolution, version defaults, header merging, Referer sanitization, If-Range validation. +/// Handles: URI resolution, version defaults, header merging, Referer sanitization, +/// If-Range validation, and default timeout injection for the channel path. /// -internal sealed class RequestEnricher +internal sealed class RequestEnricher(Func optionsFactory) { - private readonly Func _optionsFactory; - - public RequestEnricher(Func optionsFactory) - { - _optionsFactory = optionsFactory; - } - public HttpRequestMessage Enrich(HttpRequestMessage request) { - var options = _optionsFactory.Invoke(); + var options = optionsFactory.Invoke(); // Rule 1: URI resolution if (request.RequestUri is null || !request.RequestUri.IsAbsoluteUri) @@ -51,6 +47,21 @@ public HttpRequestMessage Enrich(HttpRequestMessage request) request.VersionPolicy = options.DefaultVersionPolicy; } + // Rule 2c: HTTP/3 cannot traverse an HTTP forward proxy — QUIC would silently bypass it. + // Downgrade to HTTP/2 (TLS + CONNECT tunnel) when the policy allows, otherwise fail. + if (request.Version.Major >= 3 && ProxyApplies(options, request.RequestUri)) + { + if (request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower) + { + request.Version = HttpVersion.Version20; + } + else + { + throw new HttpRequestException( + "HTTP/3 cannot be used through an HTTP proxy. Use HttpVersionPolicy.RequestVersionOrLower to allow a downgrade, or bypass the proxy for this host."); + } + } + // Rule 3: Default headers — add those absent from the request foreach (var header in options.DefaultRequestHeaders) { @@ -61,7 +72,7 @@ public HttpRequestMessage Enrich(HttpRequestMessage request) } // Rule 5: PreAuthenticate — inject Authorization header when credentials are available - if (options is { PreAuthenticate: true, Credentials: not null } && !request.Headers.Contains("Authorization")) + if (options is { PreAuthenticate: true, Credentials: not null } && !request.Headers.Contains(WellKnownHeaders.Authorization)) { InjectAuthorization(request, options.Credentials); } @@ -72,9 +83,40 @@ public HttpRequestMessage Enrich(HttpRequestMessage request) // Rule 7: If-Range validation (RFC 9110 §13.1.5) IfRangeValidator.Validate(request); + // Rule 8: Default timeout — inject CancellationToken when none is set. + // SendAsync sets the token itself; this covers the channel path. + if (!request.Options.TryGetValue(OptionsKey.CancellationTokenKey, out _)) + { + var timeout = request.Options.TryGetValue(OptionsKey.TimeoutKey, out var perRequest) + ? perRequest + : options.Timeout; + + if (timeout != System.Threading.Timeout.InfiniteTimeSpan + && timeout > TimeSpan.Zero + && timeout < TimeSpan.FromDays(1)) + { + var cts = new CancellationTokenSource(timeout); + request.SetCancellationToken(cts.Token); + + if (request.Options.TryGetValue(OptionsKey.Key, out var pending)) + { + cts.Token.UnsafeRegister( + static (state, ct) => ((PendingRequest)state!).TrySetCanceled(ct), + pending); + } + } + } + return request; } + internal static bool ProxyApplies(TurboRequestOptions options, Uri? requestUri) + { + return options is { UseProxy: true, Proxy: not null } + && requestUri is not null + && !options.Proxy.IsBypassed(requestUri); + } + /// /// Injects a Basic Authorization header using the supplied credentials. /// Uses with the request URI and "Basic" scheme. @@ -104,7 +146,7 @@ private static void InjectAuthorization(HttpRequestMessage request, ICredentials /// private static void SanitizeReferer(HttpRequestMessage request) { - if (!request.Headers.TryGetValues("Referer", out var values)) + if (!request.Headers.TryGetValues(WellKnownHeaders.Referer, out var values)) { return; } @@ -126,7 +168,7 @@ private static void SanitizeReferer(HttpRequestMessage request) && request.RequestUri is not null && request.RequestUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) { - request.Headers.Remove("Referer"); + request.Headers.Remove(WellKnownHeaders.Referer); return; } @@ -136,7 +178,7 @@ private static void SanitizeReferer(HttpRequestMessage request) if (!needsStrip) return; var sanitized = UriSanitizer.FormatAbsoluteWithoutUserInfo(refererUri); - request.Headers.Remove("Referer"); - request.Headers.TryAddWithoutValidation("Referer", sanitized); + request.Headers.Remove(WellKnownHeaders.Referer); + request.Headers.TryAddWithoutValidation(WellKnownHeaders.Referer, sanitized); } } diff --git a/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs index fb704eba2..54b5b7852 100644 --- a/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs @@ -3,7 +3,8 @@ using Akka.Streams; using Akka.Streams.Stage; using TurboHTTP.Features.AltSvc; -using static Servus.Core.Servus; +using TurboHTTP.Protocol; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; @@ -13,11 +14,15 @@ namespace TurboHTTP.Streams.Stages.Features; /// entry and upgrades the request version to 3.0 if found. /// Response direction: parses Alt-Svc headers from HTTP/1.1 and HTTP/2 responses /// and stores them in the cache for future requests. +/// When a forward proxy applies to the request, the HTTP/3 upgrade is skipped — +/// QUIC cannot traverse an HTTP proxy and would silently bypass it. /// internal sealed class AltSvcBidiStage : GraphStage> { private readonly AltSvcCache _cache; + private readonly bool _useProxy; + private readonly IWebProxy? _proxy; private readonly Inlet _inRequest = new("AltSvc.In.Request"); private readonly Outlet _outRequest = new("AltSvc.Out.Request"); @@ -26,9 +31,11 @@ internal sealed class AltSvcBidiStage public override BidiShape Shape { get; } - public AltSvcBidiStage(AltSvcCache cache) + public AltSvcBidiStage(AltSvcCache cache, bool useProxy = false, IWebProxy? proxy = null) { _cache = cache; + _useProxy = useProxy; + _proxy = proxy; Shape = new BidiShape( _inRequest, _outRequest, _inResponse, _outResponse); } @@ -49,6 +56,7 @@ public Logic(AltSvcBidiStage stage) : base(stage.Shape) { if (request.RequestUri is not null && request.Version.Major < 3 + && !ProxyApplies(stage, request.RequestUri) && stage._cache.TryGetHttp3(request.RequestUri.Host, out var entry)) { // Upgrade to HTTP/3. Use the advertised port if different from origin. @@ -92,6 +100,9 @@ public Logic(AltSvcBidiStage stage) : base(stage.Shape) onPull: () => Pull(stage._inRequest), onDownstreamFinish: _ => Cancel(stage._inRequest)); + static bool ProxyApplies(AltSvcBidiStage stage, Uri requestUri) + => stage is { _useProxy: true, _proxy: not null } && !stage._proxy.IsBypassed(requestUri); + // Response direction: parse Alt-Svc headers and update cache. SetHandler(stage._inResponse, onPush: () => @@ -99,7 +110,7 @@ public Logic(AltSvcBidiStage stage) : base(stage.Shape) var response = Grab(stage._inResponse); try { - if (response.Headers.TryGetValues("Alt-Svc", out var altSvcValues)) + if (response.Headers.TryGetValues(WellKnownHeaders.AltSvc, out var altSvcValues)) { var host = response.RequestMessage?.RequestUri?.Host; if (!string.IsNullOrEmpty(host)) diff --git a/src/TurboHTTP/Streams/Stages/Features/CacheBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/CacheBidiStage.cs index 443896b77..37e90858b 100644 --- a/src/TurboHTTP/Streams/Stages/Features/CacheBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/CacheBidiStage.cs @@ -7,7 +7,7 @@ using TurboHTTP.Diagnostics; using TurboHTTP.Features.Caching; using TurboHTTP.Protocol.Semantics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; @@ -234,7 +234,10 @@ private void MaybePullNextRequest() } } -internal sealed class CacheStateMachine +internal sealed class CacheStateMachine( + IFeatureStageOperations ops, + Cache? store, + CachePolicy policy) { internal enum CacheState { @@ -248,10 +251,6 @@ private sealed record BodyReadComplete(HttpResponseMessage Response, IMemoryOwne private sealed record BodyReadFailed(Exception Exception); - private readonly IFeatureStageOperations _ops; - private readonly Cache? _store; - private readonly CachePolicy _policy; - private HttpResponseMessage? _bufferedHitResponse; private HttpResponseMessage? _pendingCacheResponse; private bool _completionDeferred; @@ -261,16 +260,6 @@ private sealed record BodyReadFailed(Exception Exception); public int PendingAsyncCount { get; private set; } - public CacheStateMachine( - IFeatureStageOperations ops, - Cache? store, - CachePolicy policy) - { - _ops = ops; - _store = store; - _policy = policy; - } - public void SetStageActorRef(IActorRef actorRef) { _stageActorRef = actorRef; @@ -289,14 +278,14 @@ public void OnStageActorMessage(object message) { var request = msg.Response.RequestMessage!; var now = DateTimeOffset.UtcNow; - _store!.Put(request, msg.Response, msg.Owner, msg.Length, now, now); + store!.Put(request, msg.Response, msg.Owner, msg.Length, now, now); FlushPendingCacheResponse(); DecrementPendingAsync(); break; } case BodyReadFailed msg: - _ops.Log.Warning("CacheBidiStage: Async body read failed: {0}", msg.Exception.Message); + ops.Log.Warning("CacheBidiStage: Async body read failed: {0}", msg.Exception.Message); FlushPendingCacheResponse(); DecrementPendingAsync(); break; @@ -305,15 +294,15 @@ public void OnStageActorMessage(object message) public void OnRequest(HttpRequestMessage request) { - if (_store is null) + if (store is null) { - _ops.OnPushRequest(request); + ops.OnPushRequest(request); State = CacheState.Forwarded; return; } - var entry = _store.Get(request); - var result = CacheFreshnessEvaluator.Evaluate(entry, request, DateTimeOffset.UtcNow, _policy); + var entry = store.Get(request); + var result = CacheFreshnessEvaluator.Evaluate(entry, request, DateTimeOffset.UtcNow, policy); var isHit = result.Status is CacheLookupStatus.Fresh or CacheLookupStatus.Stale; EmitCacheTelemetry(request, isHit); @@ -330,9 +319,9 @@ public void OnRequest(HttpRequestMessage request) public void OnResponse(HttpResponseMessage response) { - if (_store is null || response.RequestMessage is null) + if (store is null || response.RequestMessage is null) { - _ops.OnPushResponse(response); + ops.OnPushResponse(response); State = CacheState.Idle; return; } @@ -343,13 +332,13 @@ public void OnResponse(HttpResponseMessage response) return; } - _ops.OnPushResponse(processed); + ops.OnPushResponse(processed); State = CacheState.Idle; } public void FlushBufferedHit() { - _ops.OnPushResponse(_bufferedHitResponse!); + ops.OnPushResponse(_bufferedHitResponse!); _bufferedHitResponse = null; State = CacheState.Idle; } @@ -363,7 +352,7 @@ private void FlushPendingCacheResponse() var response = _pendingCacheResponse; _pendingCacheResponse = null; - _ops.OnPushResponse(response); + ops.OnPushResponse(response); State = CacheState.Idle; } @@ -372,13 +361,13 @@ private void DecrementPendingAsync() PendingAsyncCount--; if (PendingAsyncCount == 0 && _completionDeferred) { - _ops.OnCompleteStage(); + ops.OnCompleteStage(); } } private void EmitCacheTelemetry(HttpRequestMessage request, bool isHit) { - if (request.Options.TryGetValue(TurboHttpInstrumentationExtensions.RequestActivityKey, out var rootActivity) + if (request.Options.TryGetValue(TurboClientInstrumentationExtensions.RequestActivityKey, out var rootActivity) && request.RequestUri is not null) { Tracing.AddCacheLookupEvent(rootActivity, request.RequestUri, isHit); @@ -389,7 +378,7 @@ private void EmitCacheTelemetry(HttpRequestMessage request, bool isHit) new KeyValuePair("cache.result", result)); var uri = request.RequestUri?.OriginalString ?? ""; - Tracing.For("Cache").Info(_ops, "Cache {0}: {1}", result, uri); + Tracing.For("Cache").Info(ops, "Cache {0}: {1}", result, uri); } private void HandleCacheHit(HttpRequestMessage request, CacheLookupResult result) @@ -404,7 +393,7 @@ private void HandleCacheHit(HttpRequestMessage request, CacheLookupResult result _bufferedHitResponse = cachedResponse; State = CacheState.HitBuffered; - _ops.OnSignalPullResponse(); + ops.OnSignalPullResponse(); } private void HandleCacheMiss(HttpRequestMessage request, CacheLookupResult result) @@ -419,7 +408,7 @@ private void HandleCacheMiss(HttpRequestMessage request, CacheLookupResult resul outgoing.Options.Set(CacheBidiStage.RevalidationKey, true); } - _ops.OnPushRequest(outgoing); + ops.OnPushRequest(outgoing); State = CacheState.Forwarded; } @@ -434,7 +423,7 @@ private HttpResponseMessage ProcessResponse(HttpResponseMessage response) var statusCode = (int)response.StatusCode; if (statusCode is >= 200 and < 400 && request.RequestUri is not null) { - _store!.Invalidate(request.RequestUri); + store!.Invalidate(request.RequestUri); InvalidateIfSameOrigin(request.RequestUri, response.Headers.Location); @@ -449,7 +438,7 @@ private HttpResponseMessage ProcessResponse(HttpResponseMessage response) if (response.StatusCode == HttpStatusCode.NotModified) { - var entry = _store!.Get(request); + var entry = store!.Get(request); if (entry is not null) { var merged = CacheValidationRequestBuilder.MergeNotModifiedResponse(response, entry); @@ -457,7 +446,7 @@ private HttpResponseMessage ProcessResponse(HttpResponseMessage response) var (owner, length) = Cache.RentBody(entry.Body.Span); var now = DateTimeOffset.UtcNow; - _store!.Put(request, merged, owner, length, now, now); + store!.Put(request, merged, owner, length, now, now); return merged; } @@ -511,7 +500,7 @@ private void InvalidateIfSameOrigin(Uri requestUri, Uri? targetUri) && string.Equals(requestUri.Host, targetUri.Host, StringComparison.OrdinalIgnoreCase) && requestUri.Port == targetUri.Port) { - _store!.Invalidate(targetUri); + store!.Invalidate(targetUri); } } diff --git a/src/TurboHTTP/Streams/Stages/Features/ContentEncodingBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/ContentEncodingBidiStage.cs index 95bf365f1..cda92d952 100644 --- a/src/TurboHTTP/Streams/Stages/Features/ContentEncodingBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/ContentEncodingBidiStage.cs @@ -4,7 +4,7 @@ using TurboHTTP.Internal; using TurboHTTP.Protocol; using TurboHTTP.Protocol.Semantics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; @@ -55,13 +55,11 @@ protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) internal sealed class ContentEncodingBidiLogic : GraphStageLogic, IFeatureStageOperations { private readonly ContentEncodingBidiStage _stage; - private readonly ContentEncodingBidiProcessor _processor; public ContentEncodingBidiLogic(ContentEncodingBidiStage stage) : base(stage.Shape) { _stage = stage; - _processor = - new ContentEncodingBidiProcessor(this, stage._compressionPolicy, stage._automaticDecompression); + var processor = new ContentEncodingBidiProcessor(this, stage._compressionPolicy); if (stage._compressionPolicy is not null) { @@ -71,7 +69,7 @@ public ContentEncodingBidiLogic(ContentEncodingBidiStage stage) : base(stage.Sha var request = Grab(stage._inRequest); try { - _processor.OnRequestPushWithCompression(request); + processor.OnRequestPushWithCompression(request); } catch (Exception ex) { @@ -110,7 +108,7 @@ public ContentEncodingBidiLogic(ContentEncodingBidiStage stage) : base(stage.Sha var response = Grab(stage._inResponse); try { - _processor.OnResponsePushWithDecompression(response); + processor.OnResponsePushWithDecompression(response); } catch (Exception ex) { @@ -176,30 +174,18 @@ void IFeatureStageOperations.OnCancelTimer(string key) } } -internal sealed class ContentEncodingBidiProcessor +internal sealed class ContentEncodingBidiProcessor( + IFeatureStageOperations ops, + CompressionPolicy? compressionPolicy) { - private readonly IFeatureStageOperations _ops; - private readonly CompressionPolicy? _compressionPolicy; - private readonly bool _automaticDecompression; - - public ContentEncodingBidiProcessor( - IFeatureStageOperations ops, - CompressionPolicy? compressionPolicy, - bool automaticDecompression) - { - _ops = ops; - _compressionPolicy = compressionPolicy; - _automaticDecompression = automaticDecompression; - } - public void OnRequestPushWithCompression(HttpRequestMessage request) { - _ops.OnPushRequest(CompressIfNeeded(request, _compressionPolicy!)); + ops.OnPushRequest(CompressIfNeeded(request, compressionPolicy!)); } public void OnResponsePushWithDecompression(HttpResponseMessage response) { - _ops.OnPushResponse(Decompress(response)); + ops.OnPushResponse(Decompress(response)); } private HttpRequestMessage CompressIfNeeded(HttpRequestMessage request, CompressionPolicy policy) diff --git a/src/TurboHTTP/Streams/Stages/Features/CookieBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/CookieBidiStage.cs index a1c53b837..d159d575a 100644 --- a/src/TurboHTTP/Streams/Stages/Features/CookieBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/CookieBidiStage.cs @@ -2,7 +2,8 @@ using Akka.Streams; using Akka.Streams.Stage; using TurboHTTP.Features.Cookies; -using static Servus.Core.Servus; +using TurboHTTP.Internal; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; @@ -47,7 +48,11 @@ public Logic(CookieBidiStage stage) : base(stage.Shape) if (stage._cookieJar is not null && request.RequestUri is not null) { var uri = request.RequestUri; - stage._cookieJar.AddCookiesToRequest(uri, ref request); + var firstParty = request.Options.TryGetValue(OptionsKey.FirstPartyContextKey, out var ctx) + ? ctx + : null; + var isSafeMethod = request.Method == HttpMethod.Get || request.Method == HttpMethod.Head; + stage._cookieJar.AddCookiesToRequest(uri, ref request, firstParty, isSafeMethod); Tracing.For("Cookie").Debug(this, "→ injected cookies for {0}", uri.Host); } } diff --git a/src/TurboHTTP/Streams/Stages/Features/ExpectContinueBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/ExpectContinueBidiStage.cs index acf200994..510ac3ee9 100644 --- a/src/TurboHTTP/Streams/Stages/Features/ExpectContinueBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/ExpectContinueBidiStage.cs @@ -3,7 +3,7 @@ using Akka.Streams; using Akka.Streams.Stage; using TurboHTTP.Protocol.Semantics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; diff --git a/src/TurboHTTP/Streams/Stages/Features/RedirectBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/RedirectBidiStage.cs index 889de8827..dcbcc50e1 100644 --- a/src/TurboHTTP/Streams/Stages/Features/RedirectBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/RedirectBidiStage.cs @@ -1,10 +1,9 @@ -using System.Diagnostics; using Akka.Event; using Akka.Streams; using Akka.Streams.Stage; using TurboHTTP.Diagnostics; using TurboHTTP.Protocol.Semantics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; @@ -170,7 +169,7 @@ public RedirectBidiLogic(RedirectBidiStage stage) : base(stage.Shape) }); SetHandler(stage._outResponse, - onPull: () => TryPullResponse(), + onPull: TryPullResponse, onDownstreamFinish: _ => Cancel(stage._inResponse)); } @@ -253,20 +252,11 @@ private void MaybeComplete() } } -internal sealed class RedirectStateMachine +internal sealed class RedirectStateMachine(IFeatureStageOperations ops, RedirectPolicy policy) { - private readonly IFeatureStageOperations _ops; - private readonly RedirectPolicy _policy; - private readonly Queue _readyRedirects = new(); private int _inFlightCount; - public RedirectStateMachine(IFeatureStageOperations ops, RedirectPolicy policy) - { - _ops = ops; - _policy = policy; - } - public bool CanAcceptRequest => _readyRedirects.Count == 0; public bool HasReadyRedirects => _readyRedirects.Count > 0; @@ -278,7 +268,7 @@ public RedirectStateMachine(IFeatureStageOperations ops, RedirectPolicy policy) public void OnRequest(HttpRequestMessage request) { _inFlightCount++; - _ops.OnPushRequest(request); + ops.OnPushRequest(request); } public void OnResponse(HttpResponseMessage response) @@ -288,7 +278,7 @@ public void OnResponse(HttpResponseMessage response) if (original is null || !RedirectHandler.IsRedirect(response)) { _inFlightCount--; - _ops.OnPushResponse(response); + ops.OnPushResponse(response); return; } @@ -296,22 +286,20 @@ public void OnResponse(HttpResponseMessage response) { if (!original.Options.TryGetValue(RedirectBidiStage.RedirectHandlerKey, out var handler)) { - handler = new RedirectHandler(_policy); + handler = new RedirectHandler(policy); } var newRequest = handler.BuildRedirectRequest(original, response); - Activity? rootActivity = null; - if (original.Options.TryGetValue(TurboHttpInstrumentationExtensions.RequestActivityKey, - out rootActivity)) + if (original.Options.TryGetValue(TurboClientInstrumentationExtensions.RequestActivityKey, + out var rootActivity)) { - Tracing.AddRedirectEvent( - rootActivity, newRequest.RequestUri!, (int)response.StatusCode); + Tracing.AddRedirectEvent(rootActivity, newRequest.RequestUri!, (int)response.StatusCode); } Metrics.RedirectCount().Add(1, new KeyValuePair("http.response.status_code", (int)response.StatusCode)); - Tracing.For("Redirect").Info(_ops, "Redirect followed: {0} → {2} (HTTP {1})", + Tracing.For("Redirect").Info(ops, "Redirect followed: {0} → {2} (HTTP {1})", original.RequestUri?.OriginalString ?? "", (int)response.StatusCode, newRequest.RequestUri?.OriginalString ?? ""); @@ -320,21 +308,21 @@ public void OnResponse(HttpResponseMessage response) if (rootActivity is not null) { - newRequest.Options.Set(TurboHttpInstrumentationExtensions.RequestActivityKey, rootActivity); + newRequest.Options.Set(TurboClientInstrumentationExtensions.RequestActivityKey, rootActivity); } response.Dispose(); _readyRedirects.Enqueue(newRequest); _inFlightCount--; - _ops.OnSignalPullResponse(); - _ops.OnSignalPullRequest(); + ops.OnSignalPullResponse(); + ops.OnSignalPullRequest(); } catch (RedirectException ex) { - Tracing.For("Redirect").Warning(_ops, "Redirect error: {0} (for {1})", ex.Message, original.RequestUri); + Tracing.For("Redirect").Warning(ops, "Redirect error: {0} (for {1})", ex.Message, original.RequestUri); _inFlightCount--; - _ops.OnPushResponse(response); + ops.OnPushResponse(response); } } @@ -344,7 +332,7 @@ public void FlushReadyRedirect() { var request = _readyRedirects.Dequeue(); _inFlightCount++; - _ops.OnPushRequest(request); + ops.OnPushRequest(request); } } @@ -352,7 +340,7 @@ public void OnRequestUpstreamFinish() { if (IsDrained) { - _ops.OnCompleteStage(); + ops.OnCompleteStage(); } } diff --git a/src/TurboHTTP/Streams/Stages/Features/RetryBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/RetryBidiStage.cs index 0aaae190c..d73e94767 100644 --- a/src/TurboHTTP/Streams/Stages/Features/RetryBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/RetryBidiStage.cs @@ -3,7 +3,7 @@ using Akka.Streams.Stage; using TurboHTTP.Diagnostics; using TurboHTTP.Protocol.Semantics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; @@ -161,7 +161,7 @@ public RetryBidiLogic(RetryBidiStage stage) : base(stage.Shape) }); SetHandler(stage._outResponse, - onPull: () => TryPullResponse(), + onPull: TryPullResponse, onDownstreamFinish: _ => Cancel(stage._inResponse)); } @@ -248,24 +248,15 @@ private void MaybeComplete() } } -internal sealed class RetryStateMachine +internal sealed class RetryStateMachine(IFeatureStageOperations ops, RetryPolicy policy) { private static readonly HttpRequestOptionsKey AttemptCountKey = new("TurboHTTP.RetryAttemptCount"); - private readonly IFeatureStageOperations _ops; - private readonly RetryPolicy _policy; - private readonly Queue _readyRetries = new(); private readonly Dictionary _waitingRetries = new(); private long _retryIdCounter; private int _inFlightCount; - public RetryStateMachine(IFeatureStageOperations ops, RetryPolicy policy) - { - _ops = ops; - _policy = policy; - } - public bool CanAcceptRequest => _readyRetries.Count == 0 && _readyRetries.Count + _waitingRetries.Count < RetryBidiStage.MaxPendingRetries; @@ -280,7 +271,7 @@ public RetryStateMachine(IFeatureStageOperations ops, RetryPolicy policy) public void OnRequest(HttpRequestMessage request) { _inFlightCount++; - _ops.OnPushRequest(request); + ops.OnPushRequest(request); } public void OnResponse(HttpResponseMessage response) @@ -290,7 +281,7 @@ public void OnResponse(HttpResponseMessage response) if (original is null) { _inFlightCount--; - _ops.OnPushResponse(response); + ops.OnPushResponse(response); return; } @@ -302,12 +293,12 @@ public void OnResponse(HttpResponseMessage response) networkFailure: false, bodyPartiallyConsumed: false, attemptCount: attemptCount, - policy: _policy); + policy: policy); if (!decision.ShouldRetry) { _inFlightCount--; - _ops.OnPushResponse(response); + ops.OnPushResponse(response); return; } @@ -322,15 +313,15 @@ public void OnResponse(HttpResponseMessage response) { var timerId = $"retry-{_retryIdCounter++}"; _waitingRetries[timerId] = original; - _ops.OnScheduleTimer(timerId, decision.RetryAfterDelay.Value); + ops.OnScheduleTimer(timerId, decision.RetryAfterDelay.Value); } else { _readyRetries.Enqueue(original); } - _ops.OnSignalPullResponse(); - _ops.OnSignalPullRequest(); + ops.OnSignalPullResponse(); + ops.OnSignalPullRequest(); } public void FlushReadyRetry() @@ -339,7 +330,7 @@ public void FlushReadyRetry() { var request = _readyRetries.Dequeue(); _inFlightCount++; - _ops.OnPushRequest(request); + ops.OnPushRequest(request); } } @@ -347,7 +338,7 @@ public void OnRequestUpstreamFinish() { if (IsDrained) { - _ops.OnCompleteStage(); + ops.OnCompleteStage(); } } @@ -357,7 +348,7 @@ public void OnTimer(object timerKey) if (_waitingRetries.Remove(key, out var request)) { _readyRetries.Enqueue(request); - _ops.OnSignalPullRequest(); + ops.OnSignalPullRequest(); } } @@ -369,7 +360,7 @@ public void PostStop() private void EmitRetryTelemetry(HttpRequestMessage original, int attemptCount) { - if (original.Options.TryGetValue(TurboHttpInstrumentationExtensions.RequestActivityKey, out var rootActivity)) + if (original.Options.TryGetValue(TurboClientInstrumentationExtensions.RequestActivityKey, out var rootActivity)) { Tracing.AddRetryEvent(rootActivity, attemptCount); } @@ -377,7 +368,7 @@ private void EmitRetryTelemetry(HttpRequestMessage original, int attemptCount) Metrics.RetryCount().Add(1, new KeyValuePair("http.request.method", original.Method.Method), new KeyValuePair("server.address", original.RequestUri?.Host ?? "unknown")); - Tracing.For("Retry").Warning(_ops, "Retry attempt: {0} {1} (attempt {2})", + Tracing.For("Retry").Warning(ops, "Retry attempt: {0} {1} (attempt {2})", original.Method.Method, original.RequestUri?.OriginalString ?? "", attemptCount + 1); diff --git a/src/TurboHTTP/Streams/Stages/Features/TracingBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/TracingBidiStage.cs index 6c9e6dfd2..73b4b7b86 100644 --- a/src/TurboHTTP/Streams/Stages/Features/TracingBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/TracingBidiStage.cs @@ -3,16 +3,16 @@ using Akka.Streams; using Akka.Streams.Stage; using TurboHTTP.Diagnostics; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Features; /// -/// Outermost bidirectional stage that creates and manages the root "TurboHTTP.Request" +/// Outermost bidirectional stage that creates and manages the root "TurboHTTP.ClientRequest" /// for each request flowing through the pipeline. /// /// Request direction (In1→Out1): starts a root activity via -/// and stores it in +/// and stores it in /// so downstream stages can parent child activities. /// /// @@ -127,32 +127,26 @@ void IFeatureStageOperations.OnCancelTimer(string key) } } -internal sealed class TracingBidiProcessor +internal sealed class TracingBidiProcessor(IFeatureStageOperations ops) { private static readonly HttpRequestOptionsKey RequestTimestampKey = new("TurboHTTP.RequestTimestamp"); - private readonly IFeatureStageOperations _ops; private Activity? _currentActivity; private HttpRequestMessage? _currentRequest; - public TracingBidiProcessor(IFeatureStageOperations ops) - { - _ops = ops; - } - public void OnRequestPush(HttpRequestMessage request) { var activity = Tracing.StartRequest(request); if (activity is not null) { - request.Options.Set(TurboHttpInstrumentationExtensions.RequestActivityKey, activity); + request.Options.Set(TurboClientInstrumentationExtensions.RequestActivityKey, activity); Tracing.InjectTraceContext(activity, request); _currentActivity = activity; } var method = request.Method.Method; var uri = request.RequestUri?.OriginalString ?? ""; - Tracing.For("Request").Info(_ops, "Request started: {0} {1}", method, uri); + Tracing.For("Request").Info(ops, "Request started: {0} {1}", method, uri); _currentRequest = request; @@ -165,12 +159,12 @@ public void OnRequestPush(HttpRequestMessage request) request.Options.Set(RequestTimestampKey, Stopwatch.GetTimestamp()); } - _ops.OnPushRequest(request); + ops.OnPushRequest(request); } public void OnRequestUpstreamFailure(Exception ex) { - Tracing.For("Request").Warning(_ops, $"Request failed: {ex.GetType().Name} - {ex.Message}"); + Tracing.For("Request").Warning(ops, $"Request failed: {ex.GetType().Name} - {ex.Message}"); if (_currentActivity is not null) { @@ -187,7 +181,7 @@ public void OnResponsePush(HttpResponseMessage response) var request = response.RequestMessage; if (request?.Options - .TryGetValue(TurboHttpInstrumentationExtensions.RequestActivityKey, out var activity) == true) + .TryGetValue(TurboClientInstrumentationExtensions.RequestActivityKey, out var activity) == true) { Tracing.SetHttpResponse(activity, response); activity.Stop(); @@ -201,7 +195,7 @@ public void OnResponsePush(HttpResponseMessage response) } var statusCode = (int)response.StatusCode; - Tracing.For("Request").Info(_ops, "Request completed: {0} ({1:F1}ms)", statusCode, durationMs); + Tracing.For("Request").Info(ops, "Request completed: {0} ({1:F1}ms)", statusCode, durationMs); RecordActiveRequestEnd(request); @@ -209,12 +203,12 @@ public void OnResponsePush(HttpResponseMessage response) RecordRequestMetrics(response, durationMs); - _ops.OnPushResponse(response); + ops.OnPushResponse(response); } public void OnResponseUpstreamFailure(Exception ex) { - Tracing.For("Request").Warning(_ops, $"Request failed: {ex.GetType().Name} - {ex.Message}"); + Tracing.For("Request").Warning(ops, $"Request failed: {ex.GetType().Name} - {ex.Message}"); if (_currentActivity is not null) { @@ -244,16 +238,19 @@ private static void RecordActiveRequestStart(HttpRequestMessage request) return; } - var method = TurboHttpInstrumentationExtensions.NormalizeMethod(request.Method.Method); + var method = TurboClientInstrumentationExtensions.NormalizeMethod(request.Method.Method); var host = request.RequestUri?.Host ?? "unknown"; var port = request.RequestUri?.Port ?? 0; var scheme = request.RequestUri?.Scheme ?? "https"; - Metrics.ActiveRequests().Add(1, - new KeyValuePair("http.request.method", method), - new KeyValuePair("server.address", host), - new KeyValuePair("server.port", port), - new KeyValuePair("url.scheme", scheme)); + var tags = new TagList + { + { "http.request.method", method }, + { "server.address", host }, + { "server.port", port }, + { "url.scheme", scheme } + }; + Metrics.ActiveRequests().Add(1, tags); } private static void RecordActiveRequestEnd(HttpRequestMessage? request) @@ -263,16 +260,19 @@ private static void RecordActiveRequestEnd(HttpRequestMessage? request) return; } - var method = TurboHttpInstrumentationExtensions.NormalizeMethod(request.Method.Method); + var method = TurboClientInstrumentationExtensions.NormalizeMethod(request.Method.Method); var host = request.RequestUri?.Host ?? "unknown"; var port = request.RequestUri?.Port ?? 0; var scheme = request.RequestUri?.Scheme ?? "https"; - Metrics.ActiveRequests().Add(-1, - new KeyValuePair("http.request.method", method), - new KeyValuePair("server.address", host), - new KeyValuePair("server.port", port), - new KeyValuePair("url.scheme", scheme)); + var tags = new TagList + { + { "http.request.method", method }, + { "server.address", host }, + { "server.port", port }, + { "url.scheme", scheme } + }; + Metrics.ActiveRequests().Add(-1, tags); } private static void RecordRequestMetrics(HttpResponseMessage response, double durationMs) @@ -288,35 +288,37 @@ private static void RecordRequestMetrics(HttpResponseMessage response, double du return; } - var method = TurboHttpInstrumentationExtensions.NormalizeMethod(request.Method.Method); + var method = TurboClientInstrumentationExtensions.NormalizeMethod(request.Method.Method); var statusCode = (int)response.StatusCode; var host = request.RequestUri?.Host ?? "unknown"; var port = request.RequestUri?.Port ?? 0; var scheme = request.RequestUri?.Scheme ?? "https"; - var protocolVersion = TurboHttpInstrumentationExtensions.FormatProtocolVersion(response.Version); + var protocolVersion = TurboClientInstrumentationExtensions.FormatProtocolVersion(response.Version); - Metrics.RequestCount().Add(1, - new KeyValuePair("http.request.method", method), - new KeyValuePair("http.response.status_code", statusCode), - new KeyValuePair("server.address", host)); + var countTags = new TagList + { + { "http.request.method", method }, + { "http.response.status_code", statusCode }, + { "server.address", host } + }; + Metrics.RequestCount().Add(1, countTags); - var durationTags = new List> + var durationTags = new TagList { - new("http.request.method", method), - new("http.response.status_code", statusCode), - new("network.protocol.version", protocolVersion), - new("server.address", host), - new("server.port", port), - new("url.scheme", scheme), + { "http.request.method", method }, + { "http.response.status_code", statusCode }, + { "network.protocol.version", protocolVersion }, + { "server.address", host }, + { "server.port", port }, + { "url.scheme", scheme } }; if (statusCode >= 400) { - durationTags.Add(new KeyValuePair("error.type", statusCode.ToString())); + durationTags.Add("error.type", statusCode.ToString()); } - Metrics.RequestDuration().Record(durationMs / 1000.0, - durationTags.ToArray().AsSpan()); + Metrics.RequestDuration().Record(durationMs / 1000.0, durationTags); } private void RecordFailedRequestMetrics(Exception ex) @@ -332,26 +334,32 @@ private void RecordFailedRequestMetrics(Exception ex) return; } - var method = TurboHttpInstrumentationExtensions.NormalizeMethod(request.Method.Method); + var method = TurboClientInstrumentationExtensions.NormalizeMethod(request.Method.Method); var host = request.RequestUri?.Host ?? "unknown"; var port = request.RequestUri?.Port ?? 0; var scheme = request.RequestUri?.Scheme ?? "https"; var errorType = ex.GetType().FullName ?? "unknown"; - Metrics.RequestCount().Add(1, - new KeyValuePair("http.request.method", method), - new KeyValuePair("error.type", errorType), - new KeyValuePair("server.address", host)); + var countTags = new TagList + { + { "http.request.method", method }, + { "error.type", errorType }, + { "server.address", host } + }; + Metrics.RequestCount().Add(1, countTags); if (request.Options.TryGetValue(RequestTimestampKey, out var timestamp)) { var durationSeconds = Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds / 1000.0; - Metrics.RequestDuration().Record(durationSeconds, - new KeyValuePair("http.request.method", method), - new KeyValuePair("error.type", errorType), - new KeyValuePair("server.address", host), - new KeyValuePair("server.port", port), - new KeyValuePair("url.scheme", scheme)); + var durationTags = new TagList + { + { "http.request.method", method }, + { "error.type", errorType }, + { "server.address", host }, + { "server.port", port }, + { "url.scheme", scheme } + }; + Metrics.RequestDuration().Record(durationSeconds, durationTags); } _currentRequest = null; diff --git a/src/TurboHTTP/Streams/Stages/Routing/ChannelSourceStage.cs b/src/TurboHTTP/Streams/Stages/Routing/ChannelSourceStage.cs index ed81d9658..f6cb70e04 100644 --- a/src/TurboHTTP/Streams/Stages/Routing/ChannelSourceStage.cs +++ b/src/TurboHTTP/Streams/Stages/Routing/ChannelSourceStage.cs @@ -21,8 +21,7 @@ namespace TurboHTTP.Streams.Stages.Routing; internal sealed class ChannelSourceStage : GraphStage> { private readonly Channel _channel; - private readonly TaskCompletionSource _completionTcs = - new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _completionTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Outlet _out = new("ChannelSource.Out"); @@ -161,6 +160,7 @@ private void ScheduleWait() // deadlock. CompleteStage(); } + return; } @@ -194,4 +194,4 @@ private void ScheduleWait() }); } } -} +} \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Routing/EndpointDispatchStage.cs b/src/TurboHTTP/Streams/Stages/Routing/EndpointDispatchStage.cs index fc5df60e9..0009ce8ba 100644 --- a/src/TurboHTTP/Streams/Stages/Routing/EndpointDispatchStage.cs +++ b/src/TurboHTTP/Streams/Stages/Routing/EndpointDispatchStage.cs @@ -51,12 +51,11 @@ public EndpointDispatchStage( } protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new Logic(this, inheritedAttributes); + => new Logic(this); private sealed class Logic : GraphStageLogic { private readonly EndpointDispatchStage _stage; - private readonly Attributes _inheritedAttributes; // Sink/Source pair connected to the inner flow — set on first element private SubSinkInlet? _innerSink; @@ -76,10 +75,9 @@ private sealed class Logic : GraphStageLogic private bool _innerFlowFinished; private Exception? _innerFlowFailure; - public Logic(EndpointDispatchStage stage, Attributes inheritedAttributes) : base(stage.Shape) + public Logic(EndpointDispatchStage stage) : base(stage.Shape) { _stage = stage; - _inheritedAttributes = inheritedAttributes; SetHandler(stage._in, onPush: OnPush, @@ -173,7 +171,7 @@ private void MaterializeInnerFlow(HttpRequestMessage firstRequest) // Wire SubSource → inner flow → SubSink Source.FromGraph(_innerSource.Source) - .Via(flow.Async()) + .Via(flow) .RunWith(Sink.FromGraph(_innerSink.Sink), SubFusingMaterializer); // SubSource: when inner flow pulls, we pull upstream (or push buffered first element) diff --git a/src/TurboHTTP/Streams/Stages/Routing/GroupByRequestEndpointStage.cs b/src/TurboHTTP/Streams/Stages/Routing/GroupByRequestEndpointStage.cs index ce8be45ab..aa7300c1e 100644 --- a/src/TurboHTTP/Streams/Stages/Routing/GroupByRequestEndpointStage.cs +++ b/src/TurboHTTP/Streams/Stages/Routing/GroupByRequestEndpointStage.cs @@ -9,8 +9,7 @@ namespace TurboHTTP.Streams.Stages.Routing; internal sealed class GroupByRequestEndpointStage : GraphStage>> { - internal static readonly HttpRequestOptionsKey - ConnectionAffinitySlot = new("TurboHTTP.ConnectionAffinitySlot"); + private static readonly HttpRequestOptionsKey ConnectionAffinitySlot = new("TurboHTTP.ConnectionAffinitySlot"); private readonly Inlet _in = new("GroupByRequestKey.In"); private readonly Outlet> _out = new("GroupByRequestKey.Out"); @@ -35,25 +34,25 @@ public GroupByRequestEndpointStage( } protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new Logic(this, inheritedAttributes); + => new Logic(this); - private sealed class SubflowState + private sealed class SubflowState(ChannelSourceStage channelStage, RequestEndpoint key) { private static int _nextSlotId; public readonly int SlotId = Interlocked.Increment(ref _nextSlotId); - public readonly ChannelSourceStage ChannelStage; + public readonly ChannelSourceStage ChannelStage = channelStage; /// /// Aliases for dead-slot detection. /// Replaces the former ISourceQueueWithComplete.WatchCompletionAsync() task. /// - public readonly Task WatchTask; + public readonly Task WatchTask = channelStage.Completion; public readonly Queue Pending = new(); /// The endpoint key this slot belongs to, so write-ready callbacks can look up the group. - public readonly RequestEndpoint Key; + public readonly RequestEndpoint Key = key; /// /// True when a @@ -69,13 +68,6 @@ private sealed class SubflowState public bool WatchRegistered; - public SubflowState(ChannelSourceStage channelStage, RequestEndpoint key) - { - ChannelStage = channelStage; - WatchTask = channelStage.Completion; - Key = key; - } - public bool IsDead => WatchTask.IsCompleted; /// True when this slot can accept at least one more item. @@ -88,6 +80,10 @@ public SubflowState(ChannelSourceStage channelStage, RequestEndpoint key) private sealed class SubflowGroup { private readonly Dictionary _slotsById = new(); + + // Parallel list kept in sync with _slotsById for O(1) round-robin indexing + // (Dictionary.Values.ElementAt is O(n) and allocates an enumerator per call). + private readonly List _slotList = []; private int _roundRobinIndex; public int Count => _slotsById.Count; @@ -96,13 +92,25 @@ private sealed class SubflowGroup public void AddSlot(SubflowState state) { - _slotsById[state.SlotId] = state; + if (_slotsById.TryAdd(state.SlotId, state)) + { + _slotList.Add(state); + } + else + { + _slotsById[state.SlotId] = state; + } + LastAdded = state; } public void RemoveSlot(SubflowState state) { - _slotsById.Remove(state.SlotId); + if (_slotsById.Remove(state.SlotId)) + { + _slotList.Remove(state); + } + if (ReferenceEquals(LastAdded, state)) { LastAdded = null; @@ -113,16 +121,6 @@ public void RemoveSlot(SubflowState state) public bool ContainsSlot(SubflowState state) => _slotsById.TryGetValue(state.SlotId, out var found) && ReferenceEquals(found, state); - /// Returns the first slot that has capacity, or null. - public SubflowState? FindCapacitySlot() - { - foreach (var slot in _slotsById.Values) - { - if (slot.HasCapacity) return slot; - } - - return null; - } /// Returns the alive slot with the matching slot ID, or null if not found or dead (O(1)). public SubflowState? FindBySlotId(int slotId) @@ -141,27 +139,6 @@ public bool ContainsSlot(SubflowState state) return null; } - /// Returns the alive slot with the fewest total queued items, or null. - public SubflowState? FindLeastLoaded() - { - SubflowState? best = null; - - foreach (var slot in _slotsById.Values) - { - if (slot.IsDead) - { - continue; - } - - if (best is null || slot.TotalPending < best.TotalPending) - { - best = slot; - } - } - - return best; - } - /// Returns the next alive slot in round-robin order, or null if all slots are dead. public SubflowState? NextRoundRobin() { @@ -172,13 +149,13 @@ public bool ContainsSlot(SubflowState state) var startIndex = _roundRobinIndex; - for (var i = 0; i < _slotsById.Count; i++) + for (var i = 0; i < _slotList.Count; i++) { - var idx = (startIndex + i) % _slotsById.Count; - var slot = _slotsById.Values.ElementAt(idx); + var idx = (startIndex + i) % _slotList.Count; + var slot = _slotList[idx]; if (!slot.IsDead) { - _roundRobinIndex = (idx + 1) % _slotsById.Count; + _roundRobinIndex = (idx + 1) % _slotList.Count; return slot; } } @@ -220,6 +197,8 @@ public int RemoveDead() _slotsById.Remove(id); } + _slotList.RemoveAll(static s => s.IsDead); + return dead.Count; } } @@ -234,7 +213,7 @@ private sealed class Logic : GraphStageLogic private bool _upstreamFinished; private int _totalSlotCount; - public Logic(GroupByRequestEndpointStage stage, Attributes inheritedAttributes) : base(stage.Shape) + public Logic(GroupByRequestEndpointStage stage) : base(stage.Shape) { _stage = stage; diff --git a/src/TurboHTTP/Streams/Stages/Routing/HostKeyMergeBack.cs b/src/TurboHTTP/Streams/Stages/Routing/HostKeyMergeBack.cs index 823e2c358..c5fa50365 100644 --- a/src/TurboHTTP/Streams/Stages/Routing/HostKeyMergeBack.cs +++ b/src/TurboHTTP/Streams/Stages/Routing/HostKeyMergeBack.cs @@ -10,37 +10,26 @@ namespace TurboHTTP.Streams.Stages.Routing; /// can drive our custom /// host-key grouping/merging stages. /// -internal sealed class HostKeyMergeBack : IMergeBack +internal sealed class HostKeyMergeBack( + IFlow baseFlow, + Func keyFunction, + uint substreams, + Func? maxSubstreamsPerKey = null, + Func? maxConcurrencyPerSlot = null) + : IMergeBack { - private readonly IFlow _baseFlow; - private readonly Func _keyFunction; - private readonly uint _maxSubstreams; - private readonly Func? _maxSubstreamsPerKey; - private readonly Func? _maxConcurrencyPerSlot; - - public HostKeyMergeBack(IFlow baseFlow, Func keyFunction, uint maxSubstreams, - Func? maxSubstreamsPerKey = null, - Func? maxConcurrencyPerSlot = null) - { - _baseFlow = baseFlow; - _keyFunction = keyFunction; - _maxSubstreams = maxSubstreams; - _maxSubstreamsPerKey = maxSubstreamsPerKey; - _maxConcurrencyPerSlot = maxConcurrencyPerSlot; - } - // Called by SubFlowImpl.MergeSubstreamsWithParallelism(breadth). // `flow` is the accumulated per-substream Flow built up via // SubFlowImpl.Via() calls (starts as identity, grows with each operator). public IFlow Apply(Flow flow, int breadth) { - var maxSubstreams = Convert.ToInt32(_maxSubstreams); + var maxSubstreams = Convert.ToInt32(substreams); var effectiveBreadth = breadth is <= 0 or int.MaxValue ? maxSubstreams : breadth; - return _baseFlow - .Via(new GroupByRequestEndpointStage(_keyFunction, maxSubstreams, _maxSubstreamsPerKey, _maxConcurrencyPerSlot)) + return baseFlow + .Via(new GroupByRequestEndpointStage(keyFunction, maxSubstreams, maxSubstreamsPerKey, maxConcurrencyPerSlot)) .Via(Flow.Create>() .Select(src => src.Via(flow))) .Via(new MergeSubstreamsStage(effectiveBreadth)); diff --git a/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs b/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs index 97aa01a3a..1c806469a 100644 --- a/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs +++ b/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs @@ -1,13 +1,13 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; using Akka.Actor; using Akka.Streams; using Akka.Streams.Stage; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; -using System.Diagnostics; -using System.Runtime.CompilerServices; using TurboHTTP.Diagnostics; using TurboHTTP.Server.Context.Features; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Server; @@ -39,32 +39,33 @@ public ApplicationBridgeStage( protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new Logic(this); - private sealed record DispatchCompleted(int Sequence, IFeatureCollection Features); - - private sealed record DispatchFailed(int Sequence, IFeatureCollection Features, Exception Error); + private readonly record struct DispatchCompleted(int Sequence, IFeatureCollection Features); - private sealed record ResponseReady(int Sequence, IFeatureCollection Features, Task HandlerTask); + private readonly record struct DispatchFailed(int Sequence, IFeatureCollection Features, Exception Error); - private sealed record HandlerFinished(int Sequence, IFeatureCollection Features); + private readonly record struct ResponseReady(int Sequence, IFeatureCollection Features, Task HandlerTask); - private sealed record HandlerFaulted(int Sequence, IFeatureCollection Features, Exception Error); + private readonly record struct HandlerFinished(int Sequence, IFeatureCollection Features); - private sealed record HandlerTimedOut(int Sequence, IFeatureCollection Features); + private readonly record struct HandlerFaulted(int Sequence, IFeatureCollection Features, Exception Error); - private sealed class Logic : GraphStageLogic + private sealed class Logic : TimerGraphStageLogic { + private const string SoftTimerPrefix = "soft:"; + private const string HardTimerPrefix = "hard:"; + private readonly ApplicationBridgeStage _stage; private IActorRef? _stageActor; private bool _upstreamFinished; private int _inFlight; private int _sequence; - private int _nextToEmit; private bool _downstreamReady; - private bool _unordered; - private bool _protocolDetected; - private readonly SortedDictionary _pending = []; + private readonly Queue _pending = new(); private readonly Dictionary _activeTimeouts = []; + private readonly Dictionary _activeFeatures = []; + private readonly HashSet _gracePhase = []; private readonly Dictionary _appContexts = []; + private readonly Dictionary _timerKeys = []; private readonly bool _metricsEnabled; private readonly int _backpressureThreshold; private bool _backpressureSignaled; @@ -73,9 +74,9 @@ public Logic(ApplicationBridgeStage stage) : base(stage.Shape) { _stage = stage; _metricsEnabled = Metrics.PipelineInFlight().Enabled - || Metrics.PipelinePending().Enabled - || Metrics.HandlerTimeouts().Enabled - || Tracing.IsServerTracingActive(); + || Metrics.PipelinePending().Enabled + || Metrics.HandlerTimeouts().Enabled + || Tracing.IsServerTracingActive(); _backpressureThreshold = (int)(stage._parallelism * 0.8); SetHandler(stage._in, @@ -104,19 +105,85 @@ public override void PreStart() Pull(_stage._in); } - private void OnPush() + protected override void OnTimer(object timerKey) { - var features = Grab(_stage._in); - var seq = _sequence++; + if (timerKey is not string key) + { + return; + } + + if (key.StartsWith(SoftTimerPrefix) && int.TryParse(key.AsSpan(SoftTimerPrefix.Length), out var softSeq)) + { + OnSoftTimeout(softSeq); + } + else if (key.StartsWith(HardTimerPrefix) && int.TryParse(key.AsSpan(HardTimerPrefix.Length), out var hardSeq)) + { + OnHardTimeout(hardSeq); + } + } + + private void OnSoftTimeout(int seq) + { + if (!_activeTimeouts.TryGetValue(seq, out var cts)) + { + return; + } + + cts.Cancel(); + _gracePhase.Add(seq); + if (_timerKeys.TryGetValue(seq, out var keys)) + { + ScheduleOnce(keys.Hard, _stage._handlerGracePeriod); + } + } + + private void OnHardTimeout(int seq) + { + if (!_activeTimeouts.ContainsKey(seq) || !_gracePhase.Contains(seq)) + { + return; + } + + if (!_activeFeatures.TryGetValue(seq, out var features)) + { + return; + } - if (!_protocolDetected) + CleanupTimeout(seq); + _inFlight--; + if (_metricsEnabled) { - _protocolDetected = true; - var requestFeature = features.Get(); - var protocol = requestFeature?.Protocol ?? ""; - _unordered = protocol.StartsWith("HTTP/2") || protocol.StartsWith("HTTP/3"); + Metrics.HandlerTimeouts().Add(1); + Metrics.PipelineInFlight().Add(-1); + ResetBackpressure(); + } + + DisposeAppContext(seq, null); + + if (features.Get() is not TurboHttpResponseBodyFeature + { + HasStarted: true + }) + { + var responseFeature = features.Get(); + responseFeature?.StatusCode = 503; } + CompleteResponseBody(features); + FireOnCompleted(features); + Emit(features); + + if (_upstreamFinished && _inFlight == 0) + { + CompleteStage(); + } + } + + private void OnPush() + { + var features = Grab(_stage._in); + var seq = _sequence++; + _inFlight++; if (_metricsEnabled) { @@ -135,13 +202,12 @@ private void OnPush() { Metrics.PipelineInFlight().Add(-1); } + var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } + responseFeature?.StatusCode = 500; CompleteResponseBody(features); - Emit(seq, features); + FireOnCompleted(features); + Emit(features); } TryPullNext(); @@ -159,12 +225,10 @@ private void DispatchAsync(IFeatureCollection features, int seq) { _inFlight--; var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } + responseFeature?.StatusCode = 500; CompleteResponseBody(features); - Emit(seq, features); + FireOnCompleted(features); + Emit(features); return; } @@ -176,20 +240,19 @@ private void DispatchAsync(IFeatureCollection features, int seq) _stage._application.DisposeContext(appContext, null); _appContexts.Remove(seq); CompleteResponseBody(features); - Emit(seq, features); + FireOnCompleted(features); + Emit(features); } else if (task.IsFaulted) { _inFlight--; var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } + responseFeature?.StatusCode = 500; _stage._application.DisposeContext(appContext, task.Exception); _appContexts.Remove(seq); CompleteResponseBody(features); - Emit(seq, features); + FireOnCompleted(features); + Emit(features); } else { @@ -197,16 +260,25 @@ private void DispatchAsync(IFeatureCollection features, int seq) var cts = lifetime is not null ? CancellationTokenSource.CreateLinkedTokenSource(lifetime.RequestAborted) : new CancellationTokenSource(); - cts.CancelAfter(_stage._handlerTimeout); + var softKey = string.Create(SoftTimerPrefix.Length + 10, seq, static (span, s) => + { + SoftTimerPrefix.AsSpan().CopyTo(span); + s.TryFormat(span[SoftTimerPrefix.Length..], out _); + }); + var hardKey = string.Create(HardTimerPrefix.Length + 10, seq, static (span, s) => + { + HardTimerPrefix.AsSpan().CopyTo(span); + s.TryFormat(span[HardTimerPrefix.Length..], out _); + }); + _timerKeys[seq] = (softKey, hardKey); _activeTimeouts[seq] = cts; + _activeFeatures[seq] = features; + ScheduleOnce(softKey, _stage._handlerTimeout); var bodyFeature = features.Get() as TurboHttpResponseBodyFeature; + bodyFeature?.UpgradeToPipe(); var headersReady = bodyFeature?.WhenHeadersReady; - Task.Delay(_stage._handlerTimeout + _stage._handlerGracePeriod, cts.Token) - .PipeTo(_stageActor!, - success: () => new HandlerTimedOut(seq, features)); - if (headersReady is not null) { Task.WhenAny(headersReady, task) @@ -227,37 +299,34 @@ private void OnMessage((IActorRef sender, object msg) args) switch (args.msg) { case ResponseReady(var seq, var features, var handlerTask): - if (handlerTask.IsFaulted) - { - if (features.Get() is not TurboHttpResponseBodyFeature - { - HasStarted: true - }) + if (handlerTask.IsFaulted && + features.Get() is not TurboHttpResponseBodyFeature { - var responseFeature = features.Get(); - if (responseFeature is not null) - { - responseFeature.StatusCode = 500; - } - } + HasStarted: true + }) + { + var responseFeature = features.Get(); + responseFeature?.StatusCode = 500; } if (handlerTask.IsCompleted) { CompleteResponseBody(features); + FireOnCompleted(features); _inFlight--; if (_metricsEnabled) { Metrics.PipelineInFlight().Add(-1); ResetBackpressure(); } - DisposeCts(seq); + + CleanupTimeout(seq); DisposeAppContext(seq, handlerTask.Exception); - Emit(seq, features); + Emit(features); } else { - Emit(seq, features); + Emit(features); handlerTask.PipeTo(_stageActor!, success: () => new HandlerFinished(seq, features), failure: ex => new HandlerFaulted(seq, features, ex)); @@ -266,14 +335,21 @@ private void OnMessage((IActorRef sender, object msg) args) break; case HandlerFinished(var seq, var finishedFeatures): + if (!_activeTimeouts.ContainsKey(seq)) + { + break; + } + CompleteResponseBody(finishedFeatures); + FireOnCompleted(finishedFeatures); _inFlight--; if (_metricsEnabled) { Metrics.PipelineInFlight().Add(-1); ResetBackpressure(); } - DisposeCts(seq); + + CleanupTimeout(seq); DisposeAppContext(seq, null); if (_upstreamFinished && _inFlight == 0) { @@ -283,14 +359,21 @@ private void OnMessage((IActorRef sender, object msg) args) break; case HandlerFaulted(var seq, var faultedFeatures, var error): + if (!_activeTimeouts.ContainsKey(seq)) + { + break; + } + CompleteResponseBody(faultedFeatures); + FireOnCompleted(faultedFeatures); _inFlight--; if (_metricsEnabled) { Metrics.PipelineInFlight().Add(-1); ResetBackpressure(); } - DisposeCts(seq); + + CleanupTimeout(seq); DisposeAppContext(seq, error); if (_upstreamFinished && _inFlight == 0) { @@ -300,57 +383,45 @@ private void OnMessage((IActorRef sender, object msg) args) break; case DispatchCompleted(var seq, var features): + if (!_activeTimeouts.ContainsKey(seq)) + { + break; + } + _inFlight--; if (_metricsEnabled) { Metrics.PipelineInFlight().Add(-1); ResetBackpressure(); } - DisposeCts(seq); + + CleanupTimeout(seq); DisposeAppContext(seq, null); CompleteResponseBody(features); - Emit(seq, features); + FireOnCompleted(features); + Emit(features); break; case DispatchFailed(var seq, var features, var error): + if (!_activeTimeouts.ContainsKey(seq)) + { + break; + } + _inFlight--; if (_metricsEnabled) { Metrics.PipelineInFlight().Add(-1); ResetBackpressure(); } - DisposeCts(seq); + + CleanupTimeout(seq); DisposeAppContext(seq, error); var respFeature = features.Get(); - if (respFeature is not null) - { - respFeature.StatusCode = 500; - } + respFeature?.StatusCode = 500; CompleteResponseBody(features); - Emit(seq, features); - break; - - case HandlerTimedOut(var seq, var features): - if (_activeTimeouts.TryGetValue(seq, out var cts)) - { - cts.Dispose(); - _activeTimeouts.Remove(seq); - var respFeatureTimeout = features.Get(); - if (respFeatureTimeout is not null && respFeatureTimeout.StatusCode == 200) - { - respFeatureTimeout.StatusCode = 503; - CompleteResponseBody(features); - _inFlight--; - if (_metricsEnabled) - { - Metrics.HandlerTimeouts().Add(1); - Metrics.PipelineInFlight().Add(-1); - } - DisposeAppContext(seq, null); - Emit(seq, features); - } - } - + FireOnCompleted(features); + Emit(features); break; } @@ -369,12 +440,19 @@ private void DisposeAppContext(int seq, Exception? exception) } } - private void DisposeCts(int seq) + private void CleanupTimeout(int seq) { - if (_activeTimeouts.TryGetValue(seq, out var cts)) + if (_timerKeys.Remove(seq, out var timerKeys)) + { + CancelTimer(timerKeys.Soft); + CancelTimer(timerKeys.Hard); + } + + _gracePhase.Remove(seq); + _activeFeatures.Remove(seq); + if (_activeTimeouts.Remove(seq, out var cts)) { cts.Dispose(); - _activeTimeouts.Remove(seq); } } @@ -386,44 +464,33 @@ private void TryPullNext() } } - private void Emit(int seq, IFeatureCollection features) + private void Emit(IFeatureCollection features) { - _pending[seq] = features; - if (_metricsEnabled) + if (_downstreamReady) { - Metrics.PipelinePending().Add(1); - } - TryEmitPending(); - } - - private void TryEmitPending() - { - if (_unordered) - { - if (_downstreamReady && _pending.Count > 0) - { - var seq = _pending.Keys.First(); - EmitOne(seq); - } + _downstreamReady = false; + Push(_stage._out, features); } else { - while (_downstreamReady && _pending.Count > 0 && _pending.Keys.First() == _nextToEmit) + _pending.Enqueue(features); + if (_metricsEnabled) { - EmitOne(_nextToEmit); - _nextToEmit++; + Metrics.PipelinePending().Add(1); } } } - private void EmitOne(int seq) + private void TryEmitPending() { - _downstreamReady = false; - Push(_stage._out, _pending[seq]); - _pending.Remove(seq); - if (_metricsEnabled) + if (_downstreamReady && _pending.Count > 0) { - Metrics.PipelinePending().Add(-1); + _downstreamReady = false; + Push(_stage._out, _pending.Dequeue()); + if (_metricsEnabled) + { + Metrics.PipelinePending().Add(-1); + } } } @@ -433,6 +500,14 @@ private static void CompleteResponseBody(IFeatureCollection features) bodyFeature?.Complete(); } + private static void FireOnCompleted(IFeatureCollection features) + { + if (features.Get() is TurboHttpResponseFeature responseFeature) + { + responseFeature.FireOnCompletedAsync().ContinueWith(static _ => { }, TaskContinuationOptions.OnlyOnFaulted); + } + } + [MethodImpl(MethodImplOptions.NoInlining)] private void CheckBackpressure() { @@ -453,5 +528,34 @@ private void ResetBackpressure() _backpressureSignaled = false; } } + + public override void PostStop() + { + foreach (var (_, features) in _activeFeatures) + { + if (features.Get() is TurboHttpRequestLifetimeFeature lifetime) + { + lifetime.Abort(); + } + + CompleteResponseBody(features); + } + + foreach (var (_, cts) in _activeTimeouts) + { + cts.Cancel(); + cts.Dispose(); + } + + foreach (var (_, appCtx) in _appContexts) + { + _stage._application.DisposeContext(appCtx, null); + } + + _activeFeatures.Clear(); + _activeTimeouts.Clear(); + _appContexts.Clear(); + _timerKeys.Clear(); + } } } diff --git a/src/TurboHTTP/Streams/Stages/Server/ConnectionLoggingBidiStage.cs b/src/TurboHTTP/Streams/Stages/Server/ConnectionLoggingBidiStage.cs deleted file mode 100644 index bd22e09d6..000000000 --- a/src/TurboHTTP/Streams/Stages/Server/ConnectionLoggingBidiStage.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Akka.Streams; -using Akka.Streams.Stage; -using Microsoft.Extensions.Logging; -using Servus.Akka.Transport; -using TurboHTTP.Diagnostics; - -namespace TurboHTTP.Streams.Stages.Server; - -internal sealed class ConnectionLoggingBidiStage - : GraphStage> -{ - private readonly Inlet _inboundIn = new("ConnLog.In.Inbound"); - private readonly Outlet _inboundOut = new("ConnLog.Out.Inbound"); - private readonly Inlet _outboundIn = new("ConnLog.In.Outbound"); - private readonly Outlet _outboundOut = new("ConnLog.Out.Outbound"); - - private readonly ILogger _logger; - - public ConnectionLoggingBidiStage(ILogger logger) - { - _logger = logger; - Shape = new BidiShape( - _inboundIn, _inboundOut, _outboundIn, _outboundOut); - } - - public override BidiShape Shape - { - get; - } - - protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) - => new ConnectionLoggingLogic(this); - - private sealed class ConnectionLoggingLogic : GraphStageLogic - { - private readonly ConnectionLoggingBidiStage _stage; - - public ConnectionLoggingLogic(ConnectionLoggingBidiStage stage) : base(stage.Shape) - { - _stage = stage; - - SetHandler(stage._inboundIn, - onPush: () => - { - var element = Grab(stage._inboundIn); - if (element is TransportData { Buffer: var buffer } && _stage._logger.IsEnabled(LogLevel.Debug)) - { - var dump = HexDumpFormatter.Format(buffer.Span); - _stage._logger.LogDebug("ReadAsync[{Length}]{NewLine}{Dump}", - buffer.Length, Environment.NewLine, dump); - } - Push(stage._inboundOut, element); - }, - onUpstreamFinish: () => Complete(stage._inboundOut), - onUpstreamFailure: ex => Fail(stage._inboundOut, ex)); - - SetHandler(stage._inboundOut, - onPull: () => Pull(stage._inboundIn), - onDownstreamFinish: _ => Cancel(stage._inboundIn)); - - SetHandler(stage._outboundIn, - onPush: () => - { - var element = Grab(stage._outboundIn); - if (element is TransportData { Buffer: var buffer } && _stage._logger.IsEnabled(LogLevel.Debug)) - { - var dump = HexDumpFormatter.Format(buffer.Span); - _stage._logger.LogDebug("WriteAsync[{Length}]{NewLine}{Dump}", - buffer.Length, Environment.NewLine, dump); - } - Push(stage._outboundOut, element); - }, - onUpstreamFinish: () => Complete(stage._outboundOut), - onUpstreamFailure: ex => Fail(stage._outboundOut, ex)); - - SetHandler(stage._outboundOut, - onPull: () => Pull(stage._outboundIn), - onDownstreamFinish: _ => Cancel(stage._outboundIn)); - } - } -} diff --git a/src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs index 010f068ce..88e89e62a 100644 --- a/src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs @@ -7,25 +7,20 @@ namespace TurboHTTP.Streams.Stages.Server; -internal sealed class Http10ServerConnectionStage : GraphStage +internal sealed class Http10ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) + : GraphStage { private readonly Inlet _inNetwork = new("Http10Connection.In.Network"); private readonly Outlet _outRequest = new("Http10Connection.Out.Request"); private readonly Inlet _inResponse = new("Http10Connection.In.Response"); private readonly Outlet _outNetwork = new("Http10Connection.Out.Network"); - private readonly TurboServerOptions _options; - private readonly IServiceProvider? _services; - - public Http10ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) - { - _options = options; - _services = services; - } + private readonly Http1ConnectionOptions _options = options.ToHttp1Options(); public override ServerConnectionShape Shape => new(_inNetwork, _outRequest, _inResponse, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new HttpConnectionServerStageLogic(this, ops => new Http10ServerStateMachine(_options, ops), - _services); + services, + options.MaxOutboundCoalesceCount); } diff --git a/src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs index 326472450..8380b8c40 100644 --- a/src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs @@ -7,25 +7,21 @@ namespace TurboHTTP.Streams.Stages.Server; -internal sealed class Http11ServerConnectionStage : GraphStage +internal sealed class Http11ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) + : GraphStage { private readonly Inlet _inNetwork = new("Http11Connection.In.Network"); private readonly Outlet _outRequest = new("Http11Connection.Out.Request"); private readonly Inlet _inResponse = new("Http11Connection.In.Response"); private readonly Outlet _outNetwork = new("Http11Connection.Out.Network"); - private readonly TurboServerOptions _options; - private readonly IServiceProvider? _services; - - public Http11ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) - { - _options = options; - _services = services; - } + private readonly Http1ConnectionOptions _options = options.ToHttp1Options(); + private readonly Http2ConnectionOptions _h2UpgradeOptions = options.ToHttp2Options(); public override ServerConnectionShape Shape => new(_inNetwork, _outRequest, _inResponse, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new HttpConnectionServerStageLogic(this, - ops => new Http11ServerStateMachine(_options, ops), - _services); + ops => new Http11ServerStateMachine(_options, _h2UpgradeOptions, ops), + services, + options.MaxOutboundCoalesceCount); } diff --git a/src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs index c2660d00a..db263570e 100644 --- a/src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs @@ -7,25 +7,20 @@ namespace TurboHTTP.Streams.Stages.Server; -internal sealed class Http20ServerConnectionStage : GraphStage +internal sealed class Http20ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) + : GraphStage { private readonly Inlet _inNetwork = new("Http20Connection.In.Network"); private readonly Outlet _outRequest = new("Http20Connection.Out.Request"); private readonly Inlet _inResponse = new("Http20Connection.In.Response"); private readonly Outlet _outNetwork = new("Http20Connection.Out.Network"); - private readonly TurboServerOptions _options; - private readonly IServiceProvider? _services; - - public Http20ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) - { - _options = options; - _services = services; - } + private readonly Http2ConnectionOptions _options = options.ToHttp2Options(); public override ServerConnectionShape Shape => new(_inNetwork, _outRequest, _inResponse, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new HttpConnectionServerStageLogic(this, ops => new Http2ServerStateMachine(_options, ops), - _services); + services, + options.MaxOutboundCoalesceCount); } diff --git a/src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs index 245b4b1fc..216719c3d 100644 --- a/src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs @@ -7,25 +7,20 @@ namespace TurboHTTP.Streams.Stages.Server; -internal sealed class Http30ServerConnectionStage : GraphStage +internal sealed class Http30ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) + : GraphStage { private readonly Inlet _inNetwork = new("Http30Connection.In.Network"); private readonly Outlet _outRequest = new("Http30Connection.Out.Request"); private readonly Inlet _inResponse = new("Http30Connection.In.Response"); private readonly Outlet _outNetwork = new("Http30Connection.Out.Network"); - private readonly TurboServerOptions _options; - private readonly IServiceProvider? _services; - - public Http30ServerConnectionStage(TurboServerOptions options, IServiceProvider? services = null) - { - _options = options; - _services = services; - } + private readonly Http3ConnectionOptions _options = options.ToHttp3Options(); public override ServerConnectionShape Shape => new(_inNetwork, _outRequest, _inResponse, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new HttpConnectionServerStageLogic(this, ops => new Http3ServerStateMachine(_options, ops), - _services); + services, + options.MaxOutboundCoalesceCount); } diff --git a/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs b/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs index d024b3536..32daba609 100644 --- a/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs +++ b/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net; using System.Runtime.CompilerServices; using Akka.Actor; using Akka.Event; @@ -10,13 +11,14 @@ using TurboHTTP.Protocol; using TurboHTTP.Server; using TurboHTTP.Server.Context.Features; -using static Servus.Core.Servus; +using static Servus.Senf; namespace TurboHTTP.Streams.Stages.Server; internal sealed class HttpConnectionServerStageLogic : TimerGraphStageLogic, IServerStageOperations where TSM : IServerStateMachine { + private const string TraceCategory = "Stage"; private readonly Inlet _inNetwork; private readonly Outlet _outRequest; private readonly Inlet _inResponse; @@ -25,16 +27,21 @@ internal sealed class HttpConnectionServerStageLogic : TimerGraphStageLogic private readonly TSM _sm; private readonly Queue _requestQueue = new(); private readonly Queue _outboundQueue = new(); + private bool _completeAfterFlush; private IActorRef _stageActor = ActorRefs.Nobody; private readonly IServiceProvider? _services; private TurboHttpConnectionFeature? _connectionFeature; private TlsHandshakeFeature? _tlsHandshakeFeature; private readonly bool _metricsEnabled; + private readonly int _maxCoalesce; + private Activity? _connectionActivity; + private long _connectionTimestamp; public HttpConnectionServerStageLogic( GraphStage stage, Func smFactory, - IServiceProvider? services = null) : base(stage.Shape) + IServiceProvider? services = null, + int maxCoalesce = 8) : base(stage.Shape) { var shape = stage.Shape; _inNetwork = shape.InNetwork; @@ -44,6 +51,7 @@ public HttpConnectionServerStageLogic( _services = services; _sm = smFactory(this); + _maxCoalesce = maxCoalesce; _metricsEnabled = Metrics.ServerActiveRequests().Enabled || Metrics.ServerRequestDuration().Enabled || Tracing.IsServerTracingActive(); @@ -52,15 +60,28 @@ public HttpConnectionServerStageLogic( onPush: OnNetworkPush, onUpstreamFinish: () => { - Tracing.For("Stage").Debug(this, "network upstream finished"); + Tracing.For(TraceCategory).Debug(this, "network upstream finished"); _sm.OnDownstreamFinished(); CompleteStage(); }, onUpstreamFailure: ex => { - Tracing.For("Stage").Info(this, "network upstream failure: {0}", ex.Message); + Tracing.For(TraceCategory).Info(this, "network upstream failure: {0}", ex.Message); _sm.OnDownstreamFinished(); - CompleteStage(); + if (!IsClosed(_outRequest)) + { + Complete(_outRequest); + } + + if (!IsClosed(_inResponse)) + { + Cancel(_inResponse); + } + + if (!IsClosed(_outNetwork)) + { + Complete(_outNetwork); + } }); SetHandler(_outRequest, onPull: () => @@ -87,7 +108,7 @@ public HttpConnectionServerStageLogic( } catch (Exception ex) { - Tracing.For("Stage").Error(this, "OnResponse threw: {0}", ex.Message); + Tracing.For(TraceCategory).Error(this, "OnResponse threw: {0}", ex.Message); } if (_sm.ShouldComplete) @@ -96,6 +117,7 @@ public HttpConnectionServerStageLogic( { OnResponseInstrumented(response); } + Tracing.For(TraceCategory).Debug(this, "completing after response (connection close)"); CompleteStage(); return; } @@ -116,16 +138,48 @@ public HttpConnectionServerStageLogic( }, onUpstreamFinish: () => { - Tracing.For("Stage").Debug(this, "response upstream finished"); + Tracing.For(TraceCategory).Debug(this, "response upstream finished"); CompleteStage(); }, onUpstreamFailure: _ => { _sm.OnDownstreamFinished(); - CompleteStage(); + if (!IsClosed(_outRequest)) + { + Complete(_outRequest); + } + + if (!IsClosed(_inNetwork)) + { + Cancel(_inNetwork); + } + + if (!IsClosed(_outNetwork)) + { + Complete(_outNetwork); + } }); - SetHandler(_outNetwork, onPull: OnNetworkPull); + SetHandler(_outNetwork, + onPull: OnNetworkPull, + onDownstreamFinish: _ => + { + _sm.OnDownstreamFinished(); + if (!IsClosed(_outRequest)) + { + Complete(_outRequest); + } + + if (!IsClosed(_inResponse)) + { + Cancel(_inResponse); + } + + if (!IsClosed(_inNetwork)) + { + Cancel(_inNetwork); + } + }); } public override void PreStart() @@ -137,8 +191,22 @@ public override void PreStart() private void OnStageActorMessage((IActorRef sender, object message) args) { + if (args.message is BodyResumed) + { + Tracing.For(TraceCategory).Trace(this, "body resumed"); + _sm.ResumeBody(); + if (!_sm.ShouldPauseNetwork && !HasBeenPulled(_inNetwork) && !IsClosed(_inNetwork)) + { + Pull(_inNetwork); + } + + return; + } + + Tracing.For(TraceCategory).Trace(this, "body message: {0}", args.message.GetType().Name); _sm.OnBodyMessage(args.message); TryPushOutbound(); + TryPullResponse(); } private void OnNetworkPush() @@ -148,15 +216,15 @@ private void OnNetworkPush() if (item is TransportConnected connected) { var info = connected.Info; - if (info.Remote is System.Net.IPEndPoint remoteEp) + if (info.Remote is IPEndPoint remoteEp) { var connectionFeature = new TurboHttpConnectionFeature { ConnectionId = Guid.NewGuid().ToString("N"), RemoteIpAddress = remoteEp.Address, RemotePort = remoteEp.Port, - LocalIpAddress = (info.Local as System.Net.IPEndPoint)?.Address, - LocalPort = (info.Local as System.Net.IPEndPoint)?.Port ?? 0, + LocalIpAddress = (info.Local as IPEndPoint)?.Address, + LocalPort = (info.Local as IPEndPoint)?.Port ?? 0 }; if (info.Security is { } security) @@ -166,11 +234,16 @@ private void OnNetworkPush() Protocol = security.Protocol, NegotiatedCipherSuite = security.NegotiatedCipherSuite, HostName = security.HostName, - NegotiatedApplicationProtocol = security.ApplicationProtocol, + NegotiatedApplicationProtocol = security.ApplicationProtocol }; } _connectionFeature = connectionFeature; + + if (_metricsEnabled) + { + OnConnectionEstablished(connectionFeature, info.Security is not null ? "tls" : "tcp"); + } } } @@ -180,7 +253,15 @@ private void OnNetworkPush() } catch (Exception ex) { - Tracing.For("Stage").Warning(this, "DecodeClientData threw: {0}", ex.Message); + Tracing.For(TraceCategory).Warning(this, "DecodeClientData threw: {0}", ex.Message); + } + + // The state machine signals a connection-fatal error by enqueuing a GOAWAY and setting + // ShouldComplete. Flush the GOAWAY to the network, then close the connection. + if (_sm.ShouldComplete) + { + CompleteAfterFlushingOutbound(); + return; } if (_requestQueue.Count > 0) @@ -188,7 +269,7 @@ private void OnNetworkPush() TryPushRequest(); } - if (!HasBeenPulled(_inNetwork) && !IsClosed(_inNetwork)) + if (!_sm.ShouldPauseNetwork && !HasBeenPulled(_inNetwork) && !IsClosed(_inNetwork)) { Pull(_inNetwork); } @@ -200,8 +281,7 @@ private void OnNetworkPull() { if (_outboundQueue.Count > 0) { - Push(_outNetwork, _outboundQueue.Dequeue()); - return; + PushOutbound(); } TryPullResponse(); @@ -212,6 +292,14 @@ protected override void OnTimer(object timerKey) if (timerKey is string name) { _sm.OnTimerFired(name); + + // If the state machine signals termination (data-rate violation, keep-alive timeout, etc.), + // abort the connection immediately. For H2/H3, ShouldComplete is always false, so this is safe. + if (_sm.ShouldComplete) + { + Tracing.For(TraceCategory).Info(this, "timer '{0}' triggered connection close", name); + CompleteStage(); + } } } @@ -234,7 +322,7 @@ void IServerStageOperations.OnRequest(IFeatureCollection features) } [MethodImpl(MethodImplOptions.NoInlining)] - private void OnRequestInstrumented(IFeatureCollection features) + private static void OnRequestInstrumented(IFeatureCollection features) { var requestFeature = features.Get(); if (requestFeature is null) @@ -244,25 +332,28 @@ private void OnRequestInstrumented(IFeatureCollection features) var method = requestFeature.Method; var path = requestFeature.Path; - var scheme = requestFeature.Scheme ?? "http"; + var scheme = requestFeature.Scheme; if (Metrics.ServerActiveRequests().Enabled) { - Metrics.ServerActiveRequests().Add(1, - new KeyValuePair("url.scheme", scheme), - new KeyValuePair("http.request.method", - TurboHttpInstrumentationExtensions.NormalizeMethod(method))); + var tags = new TagList + { + { "url.scheme", scheme }, + { "http.request.method", TurboClientInstrumentationExtensions.NormalizeMethod(method) } + }; + Metrics.ServerActiveRequests().Add(1, tags); } if (features is TurboFeatureCollection turbo) { turbo.RequestTimestamp = Stopwatch.GetTimestamp(); - turbo.RequestActivity = Tracing.StartRequestActivity(method, path, scheme); + var headers = requestFeature.Headers; + turbo.RequestActivity = Tracing.StartRequestActivity(method, path, scheme, headers.TraceParent, headers.TraceState); } } [MethodImpl(MethodImplOptions.NoInlining)] - private void OnResponseInstrumented(IFeatureCollection features) + private static void OnResponseInstrumented(IFeatureCollection features) { var responseFeature = features.Get(); var requestFeature = features.Get(); @@ -270,11 +361,12 @@ private void OnResponseInstrumented(IFeatureCollection features) if (requestFeature is not null && Metrics.ServerActiveRequests().Enabled) { - var scheme = requestFeature.Scheme ?? "http"; - Metrics.ServerActiveRequests().Add(-1, - new KeyValuePair("url.scheme", scheme), - new KeyValuePair("http.request.method", - TurboHttpInstrumentationExtensions.NormalizeMethod(requestFeature.Method))); + var tags = new TagList + { + { "url.scheme", requestFeature.Scheme }, + { "http.request.method", TurboClientInstrumentationExtensions.NormalizeMethod(requestFeature.Method) } + }; + Metrics.ServerActiveRequests().Add(-1, tags); } if (features is TurboFeatureCollection turbo) @@ -288,15 +380,46 @@ private void OnResponseInstrumented(IFeatureCollection features) if (turbo.RequestTimestamp > 0 && Metrics.ServerRequestDuration().Enabled && requestFeature is not null) { var elapsed = Stopwatch.GetElapsedTime(turbo.RequestTimestamp); - Metrics.ServerRequestDuration().Record(elapsed.TotalSeconds, - new KeyValuePair("http.request.method", - TurboHttpInstrumentationExtensions.NormalizeMethod(requestFeature.Method)), - new KeyValuePair("http.response.status_code", statusCode), - new KeyValuePair("url.scheme", requestFeature.Scheme ?? "http")); + var durationTags = new TagList + { + { "http.request.method", TurboClientInstrumentationExtensions.NormalizeMethod(requestFeature.Method) }, + { "http.response.status_code", statusCode }, + { "url.scheme", requestFeature.Scheme } + }; + Metrics.ServerRequestDuration().Record(elapsed.TotalSeconds, durationTags); } } } + [MethodImpl(MethodImplOptions.NoInlining)] + private void OnConnectionEstablished(TurboHttpConnectionFeature conn, string transport) + { + _connectionTimestamp = Stopwatch.GetTimestamp(); + Metrics.ActiveConnections().Add(1); + + var localAddr = conn.LocalIpAddress?.ToString() ?? "unknown"; + var localPort = conn.LocalPort; + _connectionActivity = Tracing.StartConnectionActivity(localAddr, localPort, transport); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void OnConnectionClosed() + { + Metrics.ActiveConnections().Add(-1); + + if (_connectionTimestamp > 0) + { + var elapsed = Stopwatch.GetElapsedTime(_connectionTimestamp); + Metrics.ConnectionDuration().Record(elapsed.TotalSeconds); + } + + if (_connectionActivity is { } activity) + { + Tracing.StopConnectionActivity(activity, error: null); + _connectionActivity = null; + } + } + void IServerStageOperations.OnOutbound(ITransportOutbound item) { _outboundQueue.Enqueue(item); @@ -313,7 +436,7 @@ void IServerStageOperations.OnCancelTimer(string name) IActorRef IServerStageOperations.StageActor => _stageActor; - Akka.Streams.IMaterializer IServerStageOperations.Materializer => Materializer; + IMaterializer IServerStageOperations.Materializer => Materializer; IServiceProvider? IServerStageOperations.Services => _services; @@ -321,6 +444,11 @@ void IServerStageOperations.OnCancelTimer(string name) TlsHandshakeFeature? IServerStageOperations.TlsHandshakeFeature => _tlsHandshakeFeature; + void IServerStageOperations.OnResponseBodyComplete(IFeatureCollection features) + { + FeatureCollectionFactory.Return(features); + } + private void TryPushRequest() { if (_requestQueue.Count > 0 && IsAvailable(_outRequest)) @@ -332,9 +460,90 @@ private void TryPushRequest() private void TryPushOutbound() { if (_outboundQueue.Count > 0 && IsAvailable(_outNetwork)) + { + PushOutbound(); + } + } + + private void PushOutbound() + { + if (_outboundQueue.Count == 1 || !TryCoalesceOutbound(out var flushedCount)) { Push(_outNetwork, _outboundQueue.Dequeue()); + flushedCount = 1; + } + + for (var i = 0; i < flushedCount; i++) + { + _sm.OnOutboundFlushed(); + } + + if (_completeAfterFlush && _outboundQueue.Count == 0) + { + CompleteStage(); + } + } + + private void CompleteAfterFlushingOutbound() + { + _completeAfterFlush = true; + + if (_outboundQueue.Count == 0) + { + CompleteStage(); + return; + } + + // Push now if the network outlet has demand; otherwise the next OnNetworkPull drains the + // queue and PushOutbound completes the stage once the GOAWAY has been emitted. + TryPushOutbound(); + } + + private bool TryCoalesceOutbound(out int coalescedCount) + { + coalescedCount = 0; + var totalSize = 0; + var maxBytes = _maxCoalesce * 16 * 1024; + + foreach (var item in _outboundQueue) + { + if (item is not TransportData { Buffer: var buf }) + { + break; + } + + totalSize += buf.Length; + coalescedCount++; + if (totalSize >= maxBytes) + { + break; + } + } + + if (coalescedCount < 2) + { + return false; } + + var merged = TransportBuffer.Rent(totalSize); + var dest = merged.FullMemory.Span; + var offset = 0; + + for (var i = 0; i < coalescedCount; i++) + { + var item = _outboundQueue.Dequeue(); + if (item is TransportData td) + { + td.Buffer.Span.CopyTo(dest[offset..]); + offset += td.Buffer.Length; + td.Buffer.Dispose(); + td.Return(); + } + } + + merged.Length = offset; + Push(_outNetwork, TransportData.Rent(merged)); + return true; } private void TryPullResponse() @@ -349,14 +558,20 @@ private void TryPullResponse() public override void PostStop() { - Tracing.For("Stage").Debug(this, "PostStop: draining {0} outbound, {1} requests", + Tracing.For(TraceCategory).Debug(this, "PostStop: draining {0} outbound, {1} requests", _outboundQueue.Count, _requestQueue.Count); + if (_metricsEnabled) + { + OnConnectionClosed(); + } + while (_outboundQueue.Count > 0) { - if (_outboundQueue.Dequeue() is TransportData { Buffer: var buffer }) + if (_outboundQueue.Dequeue() is TransportData td) { - buffer.Dispose(); + td.Buffer.Dispose(); + td.Return(); } } diff --git a/src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs b/src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs index b0f429f87..34a2545bd 100644 --- a/src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs +++ b/src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs @@ -19,4 +19,5 @@ internal interface IServerStageOperations IServiceProvider? Services => null; TurboHttpConnectionFeature? ConnectionFeature => null; TlsHandshakeFeature? TlsHandshakeFeature => null; + void OnResponseBodyComplete(IFeatureCollection features) { } } \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs b/src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs index 3e5955a98..9c6a0be0f 100644 --- a/src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs +++ b/src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs @@ -7,25 +7,18 @@ namespace TurboHTTP.Streams.Stages.Server; -internal sealed class ProtocolNegotiatorConnectionStage : GraphStage +internal sealed class ProtocolNegotiatorConnectionStage(TurboServerOptions options, IServiceProvider? services = null) + : GraphStage { private readonly Inlet _inNetwork = new("NegotiatorConnection.In.Network"); private readonly Outlet _outRequest = new("NegotiatorConnection.Out.Request"); private readonly Inlet _inResponse = new("NegotiatorConnection.In.Response"); private readonly Outlet _outNetwork = new("NegotiatorConnection.Out.Network"); - private readonly TurboServerOptions _options; - private readonly IServiceProvider? _services; - - public ProtocolNegotiatorConnectionStage(TurboServerOptions options, IServiceProvider? services = null) - { - _options = options; - _services = services; - } public override ServerConnectionShape Shape => new(_inNetwork, _outRequest, _inResponse, _outNetwork); protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new HttpConnectionServerStageLogic(this, - ops => new ProtocolNegotiatingStateMachine(_options, ops), - _services); + ops => new ProtocolNegotiatingStateMachine(options, ops), + services); } diff --git a/src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs b/src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs index 024f5a89b..af02ad511 100644 --- a/src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs +++ b/src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs @@ -5,24 +5,17 @@ namespace TurboHTTP.Streams.Stages.Server; -internal sealed class ServerConnectionShape : Shape +internal sealed class ServerConnectionShape( + Inlet inNetwork, + Outlet outResponse, + Inlet inRequest, + Outlet outNetwork) + : Shape { - public Inlet InNetwork { get; } - public Outlet OutRequest { get; } - public Inlet InResponse { get; } - public Outlet OutNetwork { get; } - - public ServerConnectionShape( - Inlet inNetwork, - Outlet outResponse, - Inlet inRequest, - Outlet outNetwork) - { - InNetwork = inNetwork; - OutRequest = outResponse; - InResponse = inRequest; - OutNetwork = outNetwork; - } + public Inlet InNetwork { get; } = inNetwork; + public Outlet OutRequest { get; } = outResponse; + public Inlet InResponse { get; } = inRequest; + public Outlet OutNetwork { get; } = outNetwork; public override ImmutableArray Inlets => [InNetwork, InResponse]; diff --git a/src/TurboHTTP/TurboHTTP.csproj b/src/TurboHTTP/TurboHTTP.csproj index 3c15fc169..f2e448fee 100644 --- a/src/TurboHTTP/TurboHTTP.csproj +++ b/src/TurboHTTP/TurboHTTP.csproj @@ -16,6 +16,10 @@ true true + + true true @@ -35,7 +39,10 @@ - + + @@ -46,7 +53,21 @@ + - + + + + $(TargetsForTfmSpecificBuildOutput);IncludeServusAkkaInPackage + + + + + + diff --git a/src/TurboHTTP/packages.lock.json b/src/TurboHTTP/packages.lock.json index 2c14317ca..f4a39f906 100644 --- a/src/TurboHTTP/packages.lock.json +++ b/src/TurboHTTP/packages.lock.json @@ -43,6 +43,16 @@ "resolved": "3.0.1", "contentHash": "s/s20YTVY9r9TPfTrN5g8zPF1YhwxyqO6PxUkrYTGI2B+OGPe9AdajWZrLhFqXIvqIW23fnUE4+ztrUWNU1+9g==" }, + "Servus": { + "type": "Direct", + "requested": "[0.34.0, )", + "resolved": "0.34.0", + "contentHash": "SMClC9l0Ze+3ZZoy+zdQKwIG/qqkvSXYuFhvibLqc0cjfTTUw+EKKh4ZLgGesqHnfYnmV8L2MXyzTNPk5WopKA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.0" + } + }, "Akka": { "type": "Transitive", "resolved": "1.5.68", @@ -147,22 +157,22 @@ }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.15", - "contentHash": "yzWilnNU/MvHINapPhY6iFAeApZnhToXbEBplORucn01hFc1F6ZaKt0V9dHYpUMun8WR9cSnq1ky35FWREVZbA==", + "resolved": "9.0.0", + "contentHash": "uK439QzYR0q2emLVtYzwyK3x+T5bTY4yWsd/k/ZUS9LR6Sflp8MIdhGXW8kQCd86dQD4tLqvcbLkku8qHY263Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.15" + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.15", - "contentHash": "fYrCuUAhXdeIcwPtyThTmEJ1KyUgTqwynzBCQ4n/SnpyC8/DW8GZCxGrnj9k7r0zcJy7GGaPbnZqrVRN52yZuA==", + "resolved": "9.0.0", + "contentHash": "yUKJgu81ExjvqbNWqZKshBbLntZMbMVz/P7Way2SBx7bMqA08Mfdc9O7hWDKAiSp+zPUGT6LKcSCQIPeDK+CCw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.15", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.15", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.15", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.15", - "Microsoft.Extensions.Logging.Abstractions": "9.0.15" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0" } }, "Microsoft.Extensions.Logging": { @@ -286,7 +296,9 @@ "type": "Project", "dependencies": { "Akka.Hosting": "[1.5.68, )", - "Servus.Core": "[0.33.11, )" + "Akka.Streams": "[1.5.68, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.0, )", + "Servus": "[0.34.0, )" } }, "OpenTelemetry": { @@ -315,16 +327,6 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", "OpenTelemetry.Api": "1.15.3" } - }, - "Servus.Core": { - "type": "CentralTransitive", - "requested": "[0.33.11, )", - "resolved": "0.33.11", - "contentHash": "j3MSNKNN9T53Uzkhktgwqi0cnITq/eX6CU/cwy5wN/UVCUwf2Q7al0u6ofGrQoDoqtCObRgvanU02PjYwQWCGw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.15", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.15" - } } } }