diff --git a/.github/branch-ruleset-beta-stable.json b/.github/branch-ruleset-beta-stable.json index 43983c4f9..8733f6023 100644 --- a/.github/branch-ruleset-beta-stable.json +++ b/.github/branch-ruleset-beta-stable.json @@ -35,7 +35,7 @@ { "context": ".NET / cli-e2e (macos-latest)" }, { "context": "RPS saturation / rps (ubuntu-latest)" }, { "context": "RPS saturation / rps (windows-latest)" }, - { "context": "RPS saturation / rps (macos-15-intel)" } + { "context": "RPS saturation / rps (macos-15)" } ] } }, diff --git a/.github/workflows/rps-saturation.yml b/.github/workflows/rps-saturation.yml index 3af2c05e5..9ae1d92cf 100644 --- a/.github/workflows/rps-saturation.yml +++ b/.github/workflows/rps-saturation.yml @@ -5,9 +5,10 @@ # Prefer --repeats >= 3 for publishable numbers (runner noise). Linux nginx # is the authoritative nginx baseline; nginx/Windows is same-OS only. # -# Matrix uses the 4-core public-repo runner class: ubuntu-latest / windows-latest -# (4 vCPU / 16 GiB) and macos-15-intel (4-core / 14 GiB). Do not use macos-latest -# (3-core M1 / 7 GiB) for publishable numbers — wrong size and architecture. +# Matrix uses public-repo GitHub-hosted runners: ubuntu-latest / windows-latest +# (4 vCPU / 16 GiB) and macos-15 (Apple Silicon M1, 3-core / 7 GiB). macOS is Apple +# Silicon because that is what Mac users run today; we compare products within an OS, +# not OS against OS. Pin macos-15 (not macos-latest) so the image does not change under us. # Do not mix larger or slim runners unless every OS gets the same size class. name: RPS saturation @@ -184,7 +185,7 @@ on: required: true default: '3' runner_os: - description: 'OS matrix filter (all = Win+Linux+Mac Intel)' + description: 'OS matrix filter (all = Win+Linux+Mac Apple Silicon)' required: true default: all type: choice @@ -192,7 +193,7 @@ on: - all - ubuntu-latest - windows-latest - - macos-15-intel + - macos-15 arm_shard: description: 'Comparison-group partition (all = no split; i/n = exclusive 1-based shard of wiki rows / Client×Origin wires, not individual arms)' required: true @@ -221,8 +222,8 @@ jobs: strategy: fail-fast: false matrix: - # workflow_dispatch runner_os filters to one OS; otherwise Win+Linux+Mac Intel. - os: ${{ fromJSON(inputs.runner_os == 'macos-15-intel' && '["macos-15-intel"]' || inputs.runner_os == 'ubuntu-latest' && '["ubuntu-latest"]' || inputs.runner_os == 'windows-latest' && '["windows-latest"]' || '["ubuntu-latest","windows-latest","macos-15-intel"]') }} + # workflow_dispatch runner_os filters to one OS; otherwise Win+Linux+Mac Apple Silicon. + os: ${{ fromJSON(inputs.runner_os == 'macos-15' && '["macos-15"]' || inputs.runner_os == 'ubuntu-latest' && '["ubuntu-latest"]' || inputs.runner_os == 'windows-latest' && '["windows-latest"]' || '["ubuntu-latest","windows-latest","macos-15"]') }} runs-on: ${{ matrix.os }} # Intentionally no jobs.*.container — saturation RPS on a container network measures the wrong thing. # GitHub-hosted runners hard-cap at 360 minutes — do not set timeout above that. @@ -267,20 +268,24 @@ jobs: echo "sw_vers:" sw_vers ncpu=$(sysctl -n hw.ncpu) - # hw.memsize is bytes; assert >= 4 CPUs and >= 12 GiB (macos-15-intel is 4 / 14). + # hw.memsize is bytes; assert arm64, >= 3 CPUs and >= 6 GiB (macos-15 Apple Silicon is 3 / 7). mem_bytes=$(sysctl -n hw.memsize) mem_gib=$(awk -v b="$mem_bytes" 'BEGIN { printf "%.1f", b / (1024*1024*1024) }') echo "ncpu=$ncpu" echo "RAM_GiB=$mem_gib" sysctl -n machdep.cpu.brand_string || true - if [ "$ncpu" -lt 4 ]; then - echo "Expected >=4 CPUs on macos-15-intel; got $ncpu (do not use macos-latest for publishable RPS)." >&2 + if [ "$ncpu" -lt 3 ]; then + echo "Expected >=3 CPUs on macos-15; got $ncpu." >&2 exit 1 fi - # 12 GiB floor leaves headroom under the documented 14 GiB Intel runner. - min_bytes=$((12 * 1024 * 1024 * 1024)) + if [ "$(uname -m)" != "arm64" ]; then + echo "Expected Apple Silicon (arm64) on macos-15; got $(uname -m)." >&2 + exit 1 + fi + # 6 GiB floor leaves headroom under the documented 7 GiB Apple Silicon runner. + min_bytes=$((6 * 1024 * 1024 * 1024)) if [ "$mem_bytes" -lt "$min_bytes" ]; then - echo "Expected >=12 GiB RAM on macos-15-intel; got ${mem_gib} GiB." >&2 + echo "Expected >=6 GiB RAM on macos-15; got ${mem_gib} GiB." >&2 exit 1 fi @@ -342,8 +347,8 @@ jobs: & (Join-Path $nginxDir 'nginx.exe') -v # Homebrew nginx bottles include --with-http_v3_module (OpenSSL 3). Fail hard if missing. - # macos-15-intel: Homebrew no longer mass-bottles Intel; avoid brew update/upgrade of openssl - # (already-installed bottle works; upgrading hits "no bottle available"). + # macos-15 (Apple Silicon): Homebrew bottles arm64; still skip brew update/upgrade of + # openssl (the already-installed bottle works and keeps the job fast and reproducible). - name: Install nginx (HTTP/3-capable, macOS) if: runner.os == 'macOS' env: @@ -351,12 +356,12 @@ jobs: HOMEBREW_NO_INSTALL_UPGRADE: '1' run: | set -euo pipefail - # Use preinstalled openssl@3 / pcre2 when present; do not upgrade (Intel bottles missing). + # Use preinstalled openssl@3 / pcre2 when present; do not upgrade. brew list --versions openssl@3 || brew install openssl@3 brew list --versions pcre2 || brew install pcre2 if ! brew list --versions nginx >/dev/null 2>&1; then if ! brew install nginx; then - echo "brew install nginx failed (likely missing Intel bottle); building from source…" >&2 + echo "brew install nginx failed (likely missing bottle); building from source…" >&2 brew install --build-from-source nginx fi fi @@ -478,7 +483,7 @@ jobs: uses: actions/cache@v6 with: path: ${{ runner.temp }}/haproxy-quic - key: haproxy-3.2.23-quic-osx-x64 + key: haproxy-3.2.23-quic-osx-arm64 - name: Install HAProxy with QUIC (macOS) if: runner.os == 'macOS' @@ -569,9 +574,9 @@ jobs: echo "HAProxy USE_QUIC present" # Envoy Apache 2.0 — Linux static binary from GitHub releases; macOS via Homebrew - # (HTTP/3 compiled in). Official GitHub assets are Linux-only; Intel Mac has no - # current Homebrew bottle, so fall back to the pinned Kuma/CNCF darwin-amd64 build - # of the same 1.36.7 tag (do not bazel-build Envoy on the runner). + # (HTTP/3 compiled in). Official GitHub assets are Linux-only; if Homebrew has no + # arm64 bottle, fall back to the pinned Kuma/CNCF darwin-arm64 build of the same + # 1.36.7 tag (do not bazel-build Envoy on the runner). # Windows support discontinued — skip (cells are Not possible). - name: Install Envoy (Linux) if: runner.os == 'Linux' @@ -602,8 +607,7 @@ jobs: brew services stop envoy 2>/dev/null || true pkill -x envoy 2>/dev/null || true - # Pour a bottle only. Intel Homebrew has no envoy bottle; bazel-from-source - # would burn the job timeout. ARM bottles (macos-latest) still pour here. + # Pour a bottle only; bazel-from-source would burn the job timeout. brew_has_envoy_bottle() { python3 -c ' import json, platform, subprocess, sys @@ -652,14 +656,14 @@ jobs: fi if ! command -v envoy >/dev/null 2>&1; then - echo "Downloading pinned darwin-amd64 Envoy ${ENVOY_VERSION} (Kuma/CNCF builds; official GitHub assets are Linux-only)…" >&2 + echo "Downloading pinned darwin-arm64 Envoy ${ENVOY_VERSION} (Kuma/CNCF builds; official GitHub assets are Linux-only)…" >&2 dest="${RUNNER_TEMP}/envoy" mkdir -p "$dest" - tarball="${RUNNER_TEMP}/envoy-darwin-amd64.tar.gz" + tarball="${RUNNER_TEMP}/envoy-darwin-arm64.tar.gz" if ! curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ - "https://github.com/kumahq/envoy-builds/releases/download/v${ENVOY_VERSION}/envoy-darwin-amd64-v${ENVOY_VERSION}.tar.gz" \ + "https://github.com/kumahq/envoy-builds/releases/download/v${ENVOY_VERSION}/envoy-darwin-arm64-v${ENVOY_VERSION}.tar.gz" \ -o "$tarball"; then - echo "darwin-amd64 Envoy download failed — Envoy arms will be skipped." >&2 + echo "darwin-arm64 Envoy download failed — Envoy arms will be skipped." >&2 exit 0 fi tar -xzf "$tarball" -C "$dest" @@ -670,7 +674,7 @@ jobs: bin="$(find "$dest" -type f -name envoy | awk 'NR==1 { print; exit }')" fi if [ -z "${bin}" ] || [ ! -f "$bin" ]; then - echo "darwin-amd64 Envoy tarball had no envoy binary — Envoy arms will be skipped." >&2 + echo "darwin-arm64 Envoy tarball had no envoy binary — Envoy arms will be skipped." >&2 exit 0 fi chmod +x "$bin" @@ -683,7 +687,7 @@ jobs: exit 0 fi envoy --version - echo "Envoy present (Homebrew or pinned darwin-amd64); H3 arms run when QuicListener is supported." + echo "Envoy present (Homebrew or pinned darwin-arm64); H3 arms run when QuicListener is supported." # .NET System.Net.Quic on Linux requires native libmsquic (shipped in-box on Windows). - name: Install libmsquic (HTTP/3) @@ -711,8 +715,8 @@ jobs: Write-Host "QuicListener.IsSupported=$([System.Net.Quic.QuicListener]::IsSupported)" # macOS: System.Net.Quic needs Homebrew libmsquic (and OpenSSL) on DYLD_* path. - # Prefer brew; fall back to Microsoft osx-x64 release dylibs if brew is insufficient. - - name: Install MsQuic (HTTP/3, macOS Intel) + # Prefer brew; fall back to Microsoft osx-arm64 release dylibs if brew is insufficient. + - name: Install MsQuic (HTTP/3, macOS) if: runner.os == 'macOS' shell: pwsh env: @@ -720,7 +724,7 @@ jobs: HOMEBREW_NO_INSTALL_UPGRADE: '1' run: | $ErrorActionPreference = 'Stop' - # Do not upgrade openssl@3 on Intel — new formula often has no bottle. + # Do not upgrade openssl@3; the preinstalled bottle is enough. brew install openssl@3 libmsquic $prefix = (& brew --prefix).Trim() $msquicLib = Join-Path $prefix 'opt/libmsquic/lib' @@ -745,12 +749,12 @@ jobs: } if (-not (Test-QuicSupported)) { - Write-Host 'QuicListener.IsSupported still false after brew; trying Microsoft osx-x64 libmsquic drop…' + Write-Host 'QuicListener.IsSupported still false after brew; trying Microsoft osx-arm64 libmsquic drop…' $dest = Join-Path $env:RUNNER_TEMP 'msquic-osx' New-Item -ItemType Directory -Path $dest -Force | Out-Null # Pin a known MsQuic release asset layout; adjust tag if the download 404s. $tag = 'v2.4.7' - $url = "https://github.com/microsoft/msquic/releases/download/$tag/msquic_osx-x64_$tag.zip" + $url = "https://github.com/microsoft/msquic/releases/download/$tag/msquic_osx-arm64_$tag.zip" $zip = Join-Path $env:RUNNER_TEMP 'msquic-osx.zip' try { & curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 $url -o $zip @@ -807,11 +811,11 @@ jobs: if: runner.os == 'macOS' run: | set -euo pipefail - # macos-15-intel is x86_64 — use darwin amd64 binary. + # macos-15 is arm64 - use the darwin arm64 binary. dir="${RUNNER_TEMP}/bombardier" mkdir -p "$dir" curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ - "https://github.com/codesenberg/bombardier/releases/download/v1.2.6/bombardier-darwin-amd64" \ + "https://github.com/codesenberg/bombardier/releases/download/v1.2.6/bombardier-darwin-arm64" \ -o "$dir/bombardier" chmod +x "$dir/bombardier" echo "$dir" >> "$GITHUB_PATH" @@ -892,7 +896,7 @@ jobs: $csv = Get-ChildItem tools/RpsLoadProbe/results -Filter 'rps-ramp-*.csv' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $csv) { throw 'No CSV found for compare-product gate validation' } - # Same floors on all OS: MITM Lite÷Reverse >= 0.50, Full÷Reverse >= 0.50, reverse TWP÷YARP >= 0.60. No nginx gate. + # Same floors on all OS: MITM Lite÷Reverse >= 0.40, Full÷Reverse >= 0.40, reverse TWP÷YARP >= 0.60. No nginx gate. pwsh tools/RpsLoadProbe/validate-compare-product-gates.ps1 -CsvPath $csv.FullName - name: Validate heavier YARP gates (bodies) diff --git a/benchmarks/Titanium.Web.Proxy.Benchmarks/Http2ProxyThroughputBenchmarks.cs b/benchmarks/Titanium.Web.Proxy.Benchmarks/Http2ProxyThroughputBenchmarks.cs index 8d7b73bfb..a5274e60a 100644 --- a/benchmarks/Titanium.Web.Proxy.Benchmarks/Http2ProxyThroughputBenchmarks.cs +++ b/benchmarks/Titanium.Web.Proxy.Benchmarks/Http2ProxyThroughputBenchmarks.cs @@ -51,6 +51,7 @@ public void Setup() }); originApp = builder.Build(); originApp.MapGet("/bench", () => ResponseBody); + originApp.MapPost("/bench", () => ResponseBody); originApp.Start(); proxyServer = new ProxyServer(false, false, false); @@ -63,6 +64,14 @@ public void Setup() return Task.CompletedTask; }; + // Buffer request bodies like an inspecting client (Titanium Inspector) does: this selects the + // buffered-body path (deferred HEADERS until body end), not the streamed relay path. + proxyServer.BeforeRequest += async (_, args) => + { + if (args.HttpClient.Request.HasBody) + await args.GetRequestBody(); + }; + var endPoint = new ExplicitProxyEndPoint(IPAddress.Loopback, 0); proxyServer.AddEndPoint(endPoint); proxyServer.Start(); @@ -100,6 +109,18 @@ public async Task MultiplexedGets() await Task.WhenAll(tasks); } + /// Buffered-body requests multiplexed on one connection (the x.com-shaped path). + [Benchmark] + public async Task MultiplexedBufferedPosts() + { + var tasks = new Task[ConcurrentStreams]; + for (var i = 0; i < ConcurrentStreams; i++) + tasks[i] = client.PostAsync(targetUri, new StringContent("{\"q\":\"0123456789abcdef\"}")); + var responses = await Task.WhenAll(tasks); + foreach (var response in responses) + response.Dispose(); + } + private static int GetFreeTcpPort() { var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); diff --git a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html index 144388eb5..ab4151416 100644 --- a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html +++ b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html @@ -128,7 +128,7 @@

Properties Edit this page - View Source + View Source

Current

@@ -162,7 +162,7 @@

Methods Edit this page - View Source + View Source

Dispose()

@@ -178,7 +178,7 @@
Declaration
Edit this page - View Source + View Source

MoveNext()

@@ -226,7 +226,7 @@
Exceptions
Edit this page - View Source + View Source

Reset()

@@ -280,7 +280,7 @@

Implements

Edit this page
  • - View Source + View Source
  • diff --git a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html index 213d3b4c4..fba6a05b5 100644 --- a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html +++ b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html @@ -132,7 +132,7 @@

    Constructors Edit this page - View Source + View Source

    HeaderCollection()

    @@ -150,7 +150,7 @@

    Properties Edit this page - View Source + View Source

    Headers

    @@ -181,7 +181,7 @@
    Property Value
    Edit this page - View Source + View Source

    NonUniqueHeaders

    @@ -215,7 +215,7 @@

    Methods Edit this page - View Source + View Source

    AddHeader(string, string)

    @@ -253,7 +253,7 @@
    Parameters
    Edit this page - View Source + View Source

    AddHeader(HttpHeader)

    @@ -286,7 +286,7 @@
    Parameters
    Edit this page - View Source + View Source

    AddHeaders(IEnumerable<KeyValuePair<string, string>>?)

    @@ -319,7 +319,7 @@
    Parameters
    Edit this page - View Source + View Source

    AddHeaders(IEnumerable<KeyValuePair<string, HttpHeader>>?)

    @@ -352,7 +352,7 @@
    Parameters
    Edit this page - View Source + View Source

    AddHeaders(IEnumerable<HttpHeader>?)

    @@ -385,7 +385,7 @@
    Parameters
    Edit this page - View Source + View Source

    Clear()

    @@ -401,7 +401,7 @@
    Declaration
    Edit this page - View Source + View Source

    GetAllHeaders()

    @@ -432,7 +432,7 @@
    Returns
    Edit this page - View Source + View Source

    GetEnumerator()

    @@ -478,7 +478,7 @@
    Remarks< Edit this page - View Source + View Source

    GetFirstHeader(string)

    @@ -525,7 +525,7 @@
    Returns
    Edit this page - View Source + View Source

    GetHeaders(string)

    @@ -574,7 +574,7 @@
    Returns
    Edit this page - View Source + View Source

    HeaderExists(string)

    @@ -622,7 +622,7 @@
    Returns
    Edit this page - View Source + View Source

    RemoveHeader(string)

    @@ -672,7 +672,7 @@
    Returns
    Edit this page - View Source + View Source

    RemoveHeader(KnownHeader)

    @@ -722,7 +722,7 @@
    Returns
    Edit this page - View Source + View Source

    RemoveHeader(HttpHeader)

    diff --git a/docs/api/Titanium.Web.Proxy.Http.RequestResponseBase.html b/docs/api/Titanium.Web.Proxy.Http.RequestResponseBase.html index 745180aee..9358589af 100644 --- a/docs/api/Titanium.Web.Proxy.Http.RequestResponseBase.html +++ b/docs/api/Titanium.Web.Proxy.Http.RequestResponseBase.html @@ -125,7 +125,7 @@

    Properties Edit this page - View Source + View Source

    Body

    @@ -157,7 +157,7 @@
    Property Value
    Edit this page - View Source + View Source

    BodyInternal

    @@ -188,7 +188,7 @@
    Property Value
    Edit this page - View Source + View Source

    BodyString

    @@ -221,7 +221,7 @@
    Property Value
    Edit this page - View Source + View Source

    ContentEncoding

    @@ -252,7 +252,7 @@
    Property Value
    Edit this page - View Source + View Source

    ContentLength

    @@ -283,7 +283,7 @@
    Property Value
    Edit this page - View Source + View Source

    ContentType

    @@ -314,7 +314,7 @@
    Property Value
    Edit this page - View Source + View Source

    Encoding

    @@ -345,7 +345,7 @@
    Property Value
    Edit this page - View Source + View Source

    HasBody

    @@ -376,7 +376,7 @@
    Property Value
    Edit this page - View Source + View Source

    HeaderText

    @@ -407,7 +407,7 @@
    Property Value
    Edit this page - View Source + View Source

    Headers

    @@ -438,7 +438,7 @@
    Property Value
    Edit this page - View Source + View Source

    HttpVersion

    @@ -469,7 +469,7 @@
    Property Value
    Edit this page - View Source + View Source

    IsBodyRead

    @@ -500,7 +500,7 @@
    Property Value
    Edit this page - View Source + View Source

    IsChunked

    @@ -531,7 +531,7 @@
    Property Value
    Edit this page - View Source + View Source

    KeepBody

    @@ -562,7 +562,7 @@
    Property Value
    Edit this page - View Source + View Source

    TrailingHeaders

    @@ -612,7 +612,7 @@

    Methods Edit this page - View Source + View Source

    ToString()

    diff --git a/docs/api/Titanium.Web.Proxy.Http.Response.html b/docs/api/Titanium.Web.Proxy.Http.Response.html index 1fb368e3d..f1af183cd 100644 --- a/docs/api/Titanium.Web.Proxy.Http.Response.html +++ b/docs/api/Titanium.Web.Proxy.Http.Response.html @@ -255,7 +255,7 @@
    Overrides
    Edit this page - View Source + View Source

    HeaderText

    diff --git a/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html b/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html index 755af538b..4c260ff61 100644 --- a/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html +++ b/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html @@ -151,6 +151,21 @@

    Fields different, already-enforced protection - see ProxyServer.ClientHeaderTimeoutSeconds and DeadlineRegistry) is unaffected by this family's mode.

    + + + + Http1ReplaySafety +

    Whether HTTP/1 keep-alive retries (EOF-before-response, malformed status, mid-exchange +IOException) and bare CR in header values follow the strict replay-safe rules.

    +

    + Observe (default on Balanced and + LegacyCompatible) keeps today's lenient behavior and + records a policy breach metric when the strict rule would have fired. +

    +

    + Enforce (default on PublicFacing) + refuses to replay non-idempotent methods and rejects bare CR/LF/NUL in header values. +

    diff --git a/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html b/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html index 95120c407..009caa159 100644 --- a/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html +++ b/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html @@ -96,7 +96,7 @@

    Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the + proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile.

    @@ -205,7 +205,7 @@

    Property Value
    Edit this page - View Source + View Source

    this[PolicyFamily]

    @@ -252,19 +252,19 @@

    Methods

    | - Edit this page + Edit this page - View Source + View Source -

    Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)

    +

    Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)

    Builds a snapshot with an explicit mode for every family. AllowAmbiguousFraming starts false.

    Declaration
    -
    public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget, PolicyMode http2RelayValidation = PolicyMode.Disabled)
    +
    public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget, PolicyMode http2RelayValidation = PolicyMode.Disabled, PolicyMode http1ReplaySafety = PolicyMode.Observe)
    Parameters
    @@ -306,6 +306,11 @@
    Parameters
    + + + + +
    http2RelayValidation
    PolicyModehttp1ReplaySafety
    Returns
    @@ -328,7 +333,7 @@
    Returns
    Edit this page - View Source + View Source

    With(PolicyFamily, PolicyMode)

    @@ -381,7 +386,7 @@
    Returns
    Edit this page - View Source + View Source

    WithAllObservedExceptDisabled()

    @@ -415,7 +420,7 @@
    Returns
    Edit this page - View Source + View Source

    WithAllowAmbiguousFramingEnabled()

    diff --git a/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html b/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html index ccf1ff944..5263ed4f6 100644 --- a/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html +++ b/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html @@ -294,7 +294,7 @@
    Property Value
    Edit this page - View Source + View Source

    LegacyCompatible

    @@ -398,7 +398,7 @@
    Property Value
    Edit this page - View Source + View Source

    PublicFacing

    @@ -567,7 +567,7 @@

    Methods Edit this page - View Source + View Source

    For(ProxyProfile)

    diff --git a/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html b/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html index 9ab6585fe..fe7974958 100644 --- a/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html +++ b/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html @@ -175,16 +175,17 @@
    Property Value
    Edit this page - View Source + View Source

    Default

    -

    Today's shipped values, carried forward as the Balanced profile's starting point -per the plan's rollout section: this is not a behavior change for existing traffic. Limits -newly introduced by the hardening plan (header aggregate bytes, decompression ratio, -CONTINUATION frame count, reset budget, admission cap) are set high enough that no -browser-generated traffic should reach them; they are expected to move once the benchmark -project has real numbers behind them.

    +

    Today's shipped values, carried forward as the Balanced profile's starting point. +Header, decompression, CONTINUATION, and reset budgets stay high enough that ordinary +browser traffic does not trip them. MaxConcurrentStreamsPerConnection is 1000, +matching Chrome's own concurrent-stream setting: 256 reset later requests on a single +x.com connection (REFUSED_STREAM) while the same page succeeded with no proxy. +The check runs only when a stream is opened, so traffic under the old cap takes the same +DATA path. PublicFacing pins the cap back to 256.

    MaxCertificateCacheEntries is the one deliberate exception to "unchanged for existing traffic": measurement showed process memory holding steady at ~100 MB @@ -723,7 +724,7 @@

    Methods Edit this page - View Source + View Source

    Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)

    @@ -854,7 +855,7 @@
    Exceptions
    Edit this page - View Source + View Source

    WithCertificateCacheBounds(int?, int?)

    @@ -929,7 +930,7 @@
    Exceptions
    Edit this page - View Source + View Source

    WithMaxConcurrentStreamsPerConnection(int)

    @@ -977,7 +978,7 @@
    Returns
    Edit this page - View Source + View Source

    WithMaxOriginHttp2ConnectionsPerAuthority(int)

    diff --git a/docs/api/Titanium.Web.Proxy.Options.html b/docs/api/Titanium.Web.Proxy.Options.html index f02c86eeb..a2d3b8975 100644 --- a/docs/api/Titanium.Web.Proxy.Options.html +++ b/docs/api/Titanium.Web.Proxy.Options.html @@ -101,7 +101,7 @@

    Prox through" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the - proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the + proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile.

    diff --git a/docs/index.json b/docs/index.json index 421297d91..6718f4924 100644 --- a/docs/index.json +++ b/docs/index.json @@ -502,7 +502,7 @@ "api/Titanium.Web.Proxy.Options.PolicyFamily.html": { "href": "api/Titanium.Web.Proxy.Options.PolicyFamily.html", "title": "Enum PolicyFamily | Titanium Web Proxy", - "summary": "Enum PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public enum PolicyFamily Fields Name Description AdmissionControl The global and per-endpoint admission gates that bound concurrently admitted client connections. BodyBudget Cumulative whole-body buffering limits (MaxBufferedBodyBytes and the MaxEncodedBodyBytes/MaxDecodedBodyBytes pair) enforced via Titanium.Web.Proxy.Network.Streams.BoundedWriteStream across H1, H2 and H3. DecompressionRatio The compressed-input/decompressed-output byte budgets and the expansion-ratio ceiling (MaxDecompressionRatio) applied while draining a Content-Encoding chain, so a small compressed body cannot expand unboundedly in memory before BodyBudget's own decoded-byte cap would catch it. Today, that decoded-byte cap is exactly what protects this case in practice: the decompression chain writes into the same Titanium.Web.Proxy.Network.Streams.BoundedWriteStream- wrapped target BodyBudget already bounds, so a small compressed body that expands enormously is caught the moment the decoded output crosses that limit, without needing a separately computed ratio. MaxDecompressionRatio and the encoded/decoded byte pair remain reserved for a future, more precise per-stream computation; this family's mode exists now so a profile can name it, but changing it has no additional effect while BodyBudget already covers the same paths. HeaderLimits Header line length, header count and aggregate header-byte limits (MaxHeaderLineBytes/MaxHeaderCount/ MaxHeaderAggregateBytes) intended for the request/response header-block read. Reserved, like DecompressionRatio, for numeric enforcement not yet wired to every header-reading call site; this family's mode exists so a profile can name it ahead of that work landing. The client request-line/header deadline (a different, already-enforced protection - see ProxyServer.ClientHeaderTimeoutSeconds and DeadlineRegistry) is unaffected by this family's mode. Http2AbuseBudget HTTP/2 abuse budgets: the open-header-block CONTINUATION frame-count/wall-clock bound and the peer-initiated incomplete-stream-reset budget. Http2RelayValidation Whether HPACK header blocks on the H2↔H2 compressed-relay path (httpInterceptionEnabled = false) are semantically validated per RFC 9113 §8.3. Unlike framing (always enforced, no Observe action), header semantics can be logged without corrupting connection state. Disabled (default on Balanced) skips HPACK decode entirely — maximum throughput when upstream peers are trusted. Observe decodes and records semantic violations without rejecting the stream. Does not mutate headers, so the compressed-relay MutationCount fast path is unaffected when the family is Disabled. Enforce (default on PublicFacing and AllEnforce) decodes and sends GOAWAY(PROTOCOL_ERROR) on violations." + "summary": "Enum PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public enum PolicyFamily Fields Name Description AdmissionControl The global and per-endpoint admission gates that bound concurrently admitted client connections. BodyBudget Cumulative whole-body buffering limits (MaxBufferedBodyBytes and the MaxEncodedBodyBytes/MaxDecodedBodyBytes pair) enforced via Titanium.Web.Proxy.Network.Streams.BoundedWriteStream across H1, H2 and H3. DecompressionRatio The compressed-input/decompressed-output byte budgets and the expansion-ratio ceiling (MaxDecompressionRatio) applied while draining a Content-Encoding chain, so a small compressed body cannot expand unboundedly in memory before BodyBudget's own decoded-byte cap would catch it. Today, that decoded-byte cap is exactly what protects this case in practice: the decompression chain writes into the same Titanium.Web.Proxy.Network.Streams.BoundedWriteStream- wrapped target BodyBudget already bounds, so a small compressed body that expands enormously is caught the moment the decoded output crosses that limit, without needing a separately computed ratio. MaxDecompressionRatio and the encoded/decoded byte pair remain reserved for a future, more precise per-stream computation; this family's mode exists now so a profile can name it, but changing it has no additional effect while BodyBudget already covers the same paths. HeaderLimits Header line length, header count and aggregate header-byte limits (MaxHeaderLineBytes/MaxHeaderCount/ MaxHeaderAggregateBytes) intended for the request/response header-block read. Reserved, like DecompressionRatio, for numeric enforcement not yet wired to every header-reading call site; this family's mode exists so a profile can name it ahead of that work landing. The client request-line/header deadline (a different, already-enforced protection - see ProxyServer.ClientHeaderTimeoutSeconds and DeadlineRegistry) is unaffected by this family's mode. Http1ReplaySafety Whether HTTP/1 keep-alive retries (EOF-before-response, malformed status, mid-exchange IOException) and bare CR in header values follow the strict replay-safe rules. Observe (default on Balanced and LegacyCompatible) keeps today's lenient behavior and records a policy breach metric when the strict rule would have fired. Enforce (default on PublicFacing) refuses to replay non-idempotent methods and rejects bare CR/LF/NUL in header values. Http2AbuseBudget HTTP/2 abuse budgets: the open-header-block CONTINUATION frame-count/wall-clock bound and the peer-initiated incomplete-stream-reset budget. Http2RelayValidation Whether HPACK header blocks on the H2↔H2 compressed-relay path (httpInterceptionEnabled = false) are semantically validated per RFC 9113 §8.3. Unlike framing (always enforced, no Observe action), header semantics can be logged without corrupting connection state. Disabled (default on Balanced) skips HPACK decode entirely — maximum throughput when upstream peers are trusted. Observe decodes and records semantic violations without rejecting the stream. Does not mutate headers, so the compressed-relay MutationCount fast path is unaffected when the family is Disabled. Enforce (default on PublicFacing and AllEnforce) decodes and sends GOAWAY(PROTOCOL_ERROR) on violations." }, "api/Titanium.Web.Proxy.Options.PolicyMode.html": { "href": "api/Titanium.Web.Proxy.Options.PolicyMode.html", @@ -512,7 +512,7 @@ "api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html": { "href": "api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html", "title": "Class ProxyPolicyModes | Titanium Web Proxy", - "summary": "Class ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. Inheritance object ProxyPolicyModes Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public sealed class ProxyPolicyModes Properties | Edit this page View Source AllEnforce Every family enforced - today's shipped behavior, and the starting point every profile builds from. Declaration public static ProxyPolicyModes AllEnforce { get; } Property Value Type Description ProxyPolicyModes | Edit this page View Source AllowAmbiguousFraming Off by default and absent from every profile - see the type-level remarks. Never true unless WithAllowAmbiguousFramingEnabled() was called explicitly. Declaration public bool AllowAmbiguousFraming { get; } Property Value Type Description bool | Edit this page View Source this[PolicyFamily] Returns the mode selected for family. Declaration public PolicyMode this[PolicyFamily family] { get; } Parameters Type Name Description PolicyFamily family Property Value Type Description PolicyMode Methods | Edit this page View Source Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) Builds a snapshot with an explicit mode for every family. AllowAmbiguousFraming starts false. Declaration public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget, PolicyMode http2RelayValidation = PolicyMode.Disabled) Parameters Type Name Description PolicyMode bodyBudget PolicyMode decompressionRatio PolicyMode headerLimits PolicyMode admissionControl PolicyMode http2AbuseBudget PolicyMode http2RelayValidation Returns Type Description ProxyPolicyModes | Edit this page View Source With(PolicyFamily, PolicyMode) Returns a snapshot identical to this one but with mode for family. Declaration public ProxyPolicyModes With(PolicyFamily family, PolicyMode mode) Parameters Type Name Description PolicyFamily family PolicyMode mode Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllObservedExceptDisabled() Returns a snapshot identical to this one but with every family dropped to Observe, except families already Disabled (which stay disabled - Observe would silently turn a family back on). This is the \"drop to Observe without redeploying\" runtime switch the plan's rollout section requires. Declaration public ProxyPolicyModes WithAllObservedExceptDisabled() Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllowAmbiguousFramingEnabled() The single, explicit, isolated act of relaying ambiguous HTTP/1 framing instead of rejecting it. See the type-level remarks; never call this as a side effect of applying a profile. Declaration public ProxyPolicyModes WithAllowAmbiguousFramingEnabled() Returns Type Description ProxyPolicyModes" + "summary": "Class ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. Inheritance object ProxyPolicyModes Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public sealed class ProxyPolicyModes Properties | Edit this page View Source AllEnforce Every family enforced - today's shipped behavior, and the starting point every profile builds from. Declaration public static ProxyPolicyModes AllEnforce { get; } Property Value Type Description ProxyPolicyModes | Edit this page View Source AllowAmbiguousFraming Off by default and absent from every profile - see the type-level remarks. Never true unless WithAllowAmbiguousFramingEnabled() was called explicitly. Declaration public bool AllowAmbiguousFraming { get; } Property Value Type Description bool | Edit this page View Source this[PolicyFamily] Returns the mode selected for family. Declaration public PolicyMode this[PolicyFamily family] { get; } Parameters Type Name Description PolicyFamily family Property Value Type Description PolicyMode Methods | Edit this page View Source Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) Builds a snapshot with an explicit mode for every family. AllowAmbiguousFraming starts false. Declaration public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget, PolicyMode http2RelayValidation = PolicyMode.Disabled, PolicyMode http1ReplaySafety = PolicyMode.Observe) Parameters Type Name Description PolicyMode bodyBudget PolicyMode decompressionRatio PolicyMode headerLimits PolicyMode admissionControl PolicyMode http2AbuseBudget PolicyMode http2RelayValidation PolicyMode http1ReplaySafety Returns Type Description ProxyPolicyModes | Edit this page View Source With(PolicyFamily, PolicyMode) Returns a snapshot identical to this one but with mode for family. Declaration public ProxyPolicyModes With(PolicyFamily family, PolicyMode mode) Parameters Type Name Description PolicyFamily family PolicyMode mode Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllObservedExceptDisabled() Returns a snapshot identical to this one but with every family dropped to Observe, except families already Disabled (which stay disabled - Observe would silently turn a family back on). This is the \"drop to Observe without redeploying\" runtime switch the plan's rollout section requires. Declaration public ProxyPolicyModes WithAllObservedExceptDisabled() Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllowAmbiguousFramingEnabled() The single, explicit, isolated act of relaying ambiguous HTTP/1 framing instead of rejecting it. See the type-level remarks; never call this as a side effect of applying a profile. Declaration public ProxyPolicyModes WithAllowAmbiguousFramingEnabled() Returns Type Description ProxyPolicyModes" }, "api/Titanium.Web.Proxy.Options.ProxyProfile.html": { "href": "api/Titanium.Web.Proxy.Options.ProxyProfile.html", @@ -527,7 +527,7 @@ "api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html": { "href": "api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html", "title": "Class ProxyResourceLimits | Titanium Web Proxy", - "summary": "Class ProxyResourceLimits Immutable, validated snapshot of the resource bounds a peer can make the proxy allocate: header shape, body/decompression budgets, concurrency and abuse-rate ceilings, and pool / certificate-cache sizing. Constructed only through Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?), which validates every field up front, so an invalid limit is a construction-time exception rather than a runtime surprise discovered mid-connection. A limit that can legitimately be turned off is typed as nullable with null meaning \"disabled\" - an explicit state - rather than overloading 0 or a negative number to mean the same thing. Limits that must always be enforced because disabling them would leave the proxy itself exploitable (the concurrent-stream cap, the open-header-block frame bound) are non-nullable and always validated to be strictly positive. This type has no back-reference to ProxyServer and no mutable state after construction: it is meant to be handed down to subsystems by value, not looked up through a service locator or an ambient static, so the dependency graph among consumers stays acyclic per the plan's \"Constraints on the policy layer\" section. Inheritance object ProxyResourceLimits Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public sealed class ProxyResourceLimits Properties | Edit this page View Source ConnectionPoolingEnabled Whether upstream TCP connection pooling is enabled. Disabling pooling is an explicit choice represented by this flag, not by giving MaxCachedConnectionsPerHost a sentinel value that also has to be validated as \"not zero, not negative, unless it means disabled\". Declaration public bool ConnectionPoolingEnabled { get; } Property Value Type Description bool | Edit this page View Source Default Today's shipped values, carried forward as the Balanced profile's starting point per the plan's rollout section: this is not a behavior change for existing traffic. Limits newly introduced by the hardening plan (header aggregate bytes, decompression ratio, CONTINUATION frame count, reset budget, admission cap) are set high enough that no browser-generated traffic should reach them; they are expected to move once the benchmark project has real numbers behind them. MaxCertificateCacheEntries is the one deliberate exception to \"unchanged for existing traffic\": measurement showed process memory holding steady at ~100 MB above baseline after closing every browser tab and idling for minutes, tracking the number of distinct MITM'd hosts rather than any live connection or session count. Unbounded in-memory certificate retention is a defect, not a compatibility guarantee, so the shipped default now bounds it at 1024 entries (roughly 10 MB, comfortably above real single-session browsing) rather than leaving it unbounded like every other nullable limit here defaults to. Declaration public static ProxyResourceLimits Default { get; } Property Value Type Description ProxyResourceLimits | Edit this page View Source MaxCachedConnectionsPerHost Maximum pooled connections cached per remote host. Only meaningful when ConnectionPoolingEnabled is true; always validated as strictly positive regardless, so a future caller cannot re-introduce the \"0 spins forever holding the pool lock\" defect by flipping the flag without also fixing this value. Declaration public int MaxCachedConnectionsPerHost { get; } Property Value Type Description int | Edit this page View Source MaxCertificateCacheEntries Maximum number of generated leaf certificates held in the in-memory certificate cache. Each entry holds a full X509Certificate2 with its private key, so unlike most other limits in this type this one defends against unbounded memory growth from ordinary browsing (many distinct MITM'd hosts), not just against an adversarial peer. null disables the bound and is not the shipped default - see Default. Declaration public int? MaxCertificateCacheEntries { get; } Property Value Type Description int? | Edit this page View Source MaxCertificateDiskCacheEntries Maximum number of generated leaf certificate files retained in the on-disk cache (Certificates.CertificateManager.SaveFakeCertificates), pruned independently of MaxCertificateCacheEntries. Disk is far cheaper than the in-memory cache's live X509Certificate2 handles, and a warm disk cache avoids repeating expensive certificate generation across process restarts, so this bound is deliberately independent and typically much larger (or unbounded). null disables the bound. Declaration public int? MaxCertificateDiskCacheEntries { get; } Property Value Type Description int? | Edit this page View Source MaxConcurrentClients Maximum number of concurrently admitted client connections, checked by the admission gate at handler entry/exit rather than the delayed ClientConnectionCount. null disables global admission control. Declaration public int? MaxConcurrentClients { get; } Property Value Type Description int? | Edit this page View Source MaxConcurrentStreamsPerConnection Proxy-owned cap on concurrent HTTP/2 streams per connection. Always enforced: this is the single source of truth consolidating what were previously two independent mechanisms, and is also the value advertised to the origin in the relayed SETTINGS frame so the advertised and enforced values never disagree. Declaration public int MaxConcurrentStreamsPerConnection { get; } Property Value Type Description int | Edit this page View Source MaxDecodedBodyBytes Maximum cumulative decompressed body bytes produced for a single request or response. null disables the budget. Always checked alongside MaxDecompressionRatio, since a ratio alone cannot bound total memory for a small compressed input that expands enormously without also capping the output side. Declaration public long? MaxDecodedBodyBytes { get; } Property Value Type Description long? | Edit this page View Source MaxDecompressionRatio Maximum allowed ratio of decompressed to compressed bytes. null disables the ratio check (relying on MaxDecodedBodyBytes alone). Declaration public double? MaxDecompressionRatio { get; } Property Value Type Description double? | Edit this page View Source MaxEncodedBodyBytes Maximum cumulative compressed/on-wire body bytes read for a single request or response. null disables the budget. Declaration public long? MaxEncodedBodyBytes { get; } Property Value Type Description long? | Edit this page View Source MaxHeaderAggregateBytes Maximum aggregate size of all header fields in one request or response, in bytes. Declaration public long MaxHeaderAggregateBytes { get; } Property Value Type Description long | Edit this page View Source MaxHeaderCount Maximum number of header fields accepted in one request or response. Declaration public int MaxHeaderCount { get; } Property Value Type Description int | Edit this page View Source MaxHeaderLineBytes Maximum length of a single header line (request/status line or one header field), in bytes. Declaration public long MaxHeaderLineBytes { get; } Property Value Type Description long | Edit this page View Source MaxOpenHeaderBlockDuration Maximum wall-clock duration an HTTP/2 header block may stay open (from the initial HEADERS/PUSH_PROMISE frame sent without END_HEADERS through its terminating CONTINUATION), independent of MaxOpenHeaderBlockFrames. Bounds a slow CONTINUATION-trickle variant that stays under the frame-count cap by pacing itself, which a frame-count-only bound cannot catch on its own. Always enforced. Declaration public TimeSpan MaxOpenHeaderBlockDuration { get; } Property Value Type Description TimeSpan | Edit this page View Source MaxOpenHeaderBlockFrames Maximum number of CONTINUATION frames tolerated for a single open HTTP/2 header block, independent of the existing byte cap. Always enforced: zero-length CONTINUATION frames never trip a byte-based check, and only one header block may be open per connection direction, so an unbounded frame count also head-of-line blocks every other stream. Declaration public int MaxOpenHeaderBlockFrames { get; } Property Value Type Description int | Edit this page View Source MaxOriginHttp2ConnectionsPerAuthority Maximum number of concurrent origin HTTP/2 connections the proxy may open for one authority (host+port+scheme) when multiplexing streams from a single client connection (SocketsHttpHandler EnableMultipleHttp2Connections analog). Always at least 1. Declaration public int MaxOriginHttp2ConnectionsPerAuthority { get; } Property Value Type Description int | Edit this page View Source MaxPeerInitiatedIncompleteStreamResets Maximum number of peer-initiated resets of streams that never completed, per connection, before the proxy tears the connection down. Proxy-initiated resets (e.g. in response to a client cancellation) do not count. null disables the reset budget. Declaration public int? MaxPeerInitiatedIncompleteStreamResets { get; } Property Value Type Description int? Methods | Edit this page View Source Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?) Validates and constructs a ProxyResourceLimits snapshot. Every non-nullable bound must be strictly positive; every nullable bound, if supplied, must also be strictly positive (use null to disable rather than a sentinel number). Declaration public static ProxyResourceLimits Create(long maxHeaderLineBytes, int maxHeaderCount, long maxHeaderAggregateBytes, long? maxEncodedBodyBytes, long? maxDecodedBodyBytes, double? maxDecompressionRatio, int? maxConcurrentClients, int maxConcurrentStreamsPerConnection, int? maxPeerInitiatedIncompleteStreamResets, int maxOpenHeaderBlockFrames, TimeSpan maxOpenHeaderBlockDuration, bool connectionPoolingEnabled, int maxCachedConnectionsPerHost, int? maxCertificateCacheEntries) Parameters Type Name Description long maxHeaderLineBytes int maxHeaderCount long maxHeaderAggregateBytes long? maxEncodedBodyBytes long? maxDecodedBodyBytes double? maxDecompressionRatio int? maxConcurrentClients int maxConcurrentStreamsPerConnection int? maxPeerInitiatedIncompleteStreamResets int maxOpenHeaderBlockFrames TimeSpan maxOpenHeaderBlockDuration bool connectionPoolingEnabled int maxCachedConnectionsPerHost int? maxCertificateCacheEntries Returns Type Description ProxyResourceLimits Exceptions Type Condition ArgumentOutOfRangeException A supplied value is zero or negative. | Edit this page View Source WithCertificateCacheBounds(int?, int?) Returns a copy of this instance with MaxCertificateCacheEntries and MaxCertificateDiskCacheEntries replaced, leaving every other limit unchanged. Added instead of extending Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?) - which is public API already shipped with a fixed parameter list - so that adding the independent disk-cache bound could not be a breaking change for existing callers. Declaration public ProxyResourceLimits WithCertificateCacheBounds(int? maxCertificateCacheEntries, int? maxCertificateDiskCacheEntries) Parameters Type Name Description int? maxCertificateCacheEntries See MaxCertificateCacheEntries. null disables the bound. int? maxCertificateDiskCacheEntries See MaxCertificateDiskCacheEntries. null disables the bound. Returns Type Description ProxyResourceLimits Exceptions Type Condition ArgumentOutOfRangeException A supplied value is zero or negative. | Edit this page View Source WithMaxConcurrentStreamsPerConnection(int) Returns a copy with MaxConcurrentStreamsPerConnection replaced. Declaration public ProxyResourceLimits WithMaxConcurrentStreamsPerConnection(int maxConcurrentStreamsPerConnection) Parameters Type Name Description int maxConcurrentStreamsPerConnection Returns Type Description ProxyResourceLimits | Edit this page View Source WithMaxOriginHttp2ConnectionsPerAuthority(int) Returns a copy with MaxOriginHttp2ConnectionsPerAuthority replaced. Declaration public ProxyResourceLimits WithMaxOriginHttp2ConnectionsPerAuthority(int maxOriginHttp2ConnectionsPerAuthority) Parameters Type Name Description int maxOriginHttp2ConnectionsPerAuthority Returns Type Description ProxyResourceLimits" + "summary": "Class ProxyResourceLimits Immutable, validated snapshot of the resource bounds a peer can make the proxy allocate: header shape, body/decompression budgets, concurrency and abuse-rate ceilings, and pool / certificate-cache sizing. Constructed only through Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?), which validates every field up front, so an invalid limit is a construction-time exception rather than a runtime surprise discovered mid-connection. A limit that can legitimately be turned off is typed as nullable with null meaning \"disabled\" - an explicit state - rather than overloading 0 or a negative number to mean the same thing. Limits that must always be enforced because disabling them would leave the proxy itself exploitable (the concurrent-stream cap, the open-header-block frame bound) are non-nullable and always validated to be strictly positive. This type has no back-reference to ProxyServer and no mutable state after construction: it is meant to be handed down to subsystems by value, not looked up through a service locator or an ambient static, so the dependency graph among consumers stays acyclic per the plan's \"Constraints on the policy layer\" section. Inheritance object ProxyResourceLimits Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public sealed class ProxyResourceLimits Properties | Edit this page View Source ConnectionPoolingEnabled Whether upstream TCP connection pooling is enabled. Disabling pooling is an explicit choice represented by this flag, not by giving MaxCachedConnectionsPerHost a sentinel value that also has to be validated as \"not zero, not negative, unless it means disabled\". Declaration public bool ConnectionPoolingEnabled { get; } Property Value Type Description bool | Edit this page View Source Default Today's shipped values, carried forward as the Balanced profile's starting point. Header, decompression, CONTINUATION, and reset budgets stay high enough that ordinary browser traffic does not trip them. MaxConcurrentStreamsPerConnection is 1000, matching Chrome's own concurrent-stream setting: 256 reset later requests on a single x.com connection (REFUSED_STREAM) while the same page succeeded with no proxy. The check runs only when a stream is opened, so traffic under the old cap takes the same DATA path. PublicFacing pins the cap back to 256. MaxCertificateCacheEntries is the one deliberate exception to \"unchanged for existing traffic\": measurement showed process memory holding steady at ~100 MB above baseline after closing every browser tab and idling for minutes, tracking the number of distinct MITM'd hosts rather than any live connection or session count. Unbounded in-memory certificate retention is a defect, not a compatibility guarantee, so the shipped default now bounds it at 1024 entries (roughly 10 MB, comfortably above real single-session browsing) rather than leaving it unbounded like every other nullable limit here defaults to. Declaration public static ProxyResourceLimits Default { get; } Property Value Type Description ProxyResourceLimits | Edit this page View Source MaxCachedConnectionsPerHost Maximum pooled connections cached per remote host. Only meaningful when ConnectionPoolingEnabled is true; always validated as strictly positive regardless, so a future caller cannot re-introduce the \"0 spins forever holding the pool lock\" defect by flipping the flag without also fixing this value. Declaration public int MaxCachedConnectionsPerHost { get; } Property Value Type Description int | Edit this page View Source MaxCertificateCacheEntries Maximum number of generated leaf certificates held in the in-memory certificate cache. Each entry holds a full X509Certificate2 with its private key, so unlike most other limits in this type this one defends against unbounded memory growth from ordinary browsing (many distinct MITM'd hosts), not just against an adversarial peer. null disables the bound and is not the shipped default - see Default. Declaration public int? MaxCertificateCacheEntries { get; } Property Value Type Description int? | Edit this page View Source MaxCertificateDiskCacheEntries Maximum number of generated leaf certificate files retained in the on-disk cache (Certificates.CertificateManager.SaveFakeCertificates), pruned independently of MaxCertificateCacheEntries. Disk is far cheaper than the in-memory cache's live X509Certificate2 handles, and a warm disk cache avoids repeating expensive certificate generation across process restarts, so this bound is deliberately independent and typically much larger (or unbounded). null disables the bound. Declaration public int? MaxCertificateDiskCacheEntries { get; } Property Value Type Description int? | Edit this page View Source MaxConcurrentClients Maximum number of concurrently admitted client connections, checked by the admission gate at handler entry/exit rather than the delayed ClientConnectionCount. null disables global admission control. Declaration public int? MaxConcurrentClients { get; } Property Value Type Description int? | Edit this page View Source MaxConcurrentStreamsPerConnection Proxy-owned cap on concurrent HTTP/2 streams per connection. Always enforced: this is the single source of truth consolidating what were previously two independent mechanisms, and is also the value advertised to the origin in the relayed SETTINGS frame so the advertised and enforced values never disagree. Declaration public int MaxConcurrentStreamsPerConnection { get; } Property Value Type Description int | Edit this page View Source MaxDecodedBodyBytes Maximum cumulative decompressed body bytes produced for a single request or response. null disables the budget. Always checked alongside MaxDecompressionRatio, since a ratio alone cannot bound total memory for a small compressed input that expands enormously without also capping the output side. Declaration public long? MaxDecodedBodyBytes { get; } Property Value Type Description long? | Edit this page View Source MaxDecompressionRatio Maximum allowed ratio of decompressed to compressed bytes. null disables the ratio check (relying on MaxDecodedBodyBytes alone). Declaration public double? MaxDecompressionRatio { get; } Property Value Type Description double? | Edit this page View Source MaxEncodedBodyBytes Maximum cumulative compressed/on-wire body bytes read for a single request or response. null disables the budget. Declaration public long? MaxEncodedBodyBytes { get; } Property Value Type Description long? | Edit this page View Source MaxHeaderAggregateBytes Maximum aggregate size of all header fields in one request or response, in bytes. Declaration public long MaxHeaderAggregateBytes { get; } Property Value Type Description long | Edit this page View Source MaxHeaderCount Maximum number of header fields accepted in one request or response. Declaration public int MaxHeaderCount { get; } Property Value Type Description int | Edit this page View Source MaxHeaderLineBytes Maximum length of a single header line (request/status line or one header field), in bytes. Declaration public long MaxHeaderLineBytes { get; } Property Value Type Description long | Edit this page View Source MaxOpenHeaderBlockDuration Maximum wall-clock duration an HTTP/2 header block may stay open (from the initial HEADERS/PUSH_PROMISE frame sent without END_HEADERS through its terminating CONTINUATION), independent of MaxOpenHeaderBlockFrames. Bounds a slow CONTINUATION-trickle variant that stays under the frame-count cap by pacing itself, which a frame-count-only bound cannot catch on its own. Always enforced. Declaration public TimeSpan MaxOpenHeaderBlockDuration { get; } Property Value Type Description TimeSpan | Edit this page View Source MaxOpenHeaderBlockFrames Maximum number of CONTINUATION frames tolerated for a single open HTTP/2 header block, independent of the existing byte cap. Always enforced: zero-length CONTINUATION frames never trip a byte-based check, and only one header block may be open per connection direction, so an unbounded frame count also head-of-line blocks every other stream. Declaration public int MaxOpenHeaderBlockFrames { get; } Property Value Type Description int | Edit this page View Source MaxOriginHttp2ConnectionsPerAuthority Maximum number of concurrent origin HTTP/2 connections the proxy may open for one authority (host+port+scheme) when multiplexing streams from a single client connection (SocketsHttpHandler EnableMultipleHttp2Connections analog). Always at least 1. Declaration public int MaxOriginHttp2ConnectionsPerAuthority { get; } Property Value Type Description int | Edit this page View Source MaxPeerInitiatedIncompleteStreamResets Maximum number of peer-initiated resets of streams that never completed, per connection, before the proxy tears the connection down. Proxy-initiated resets (e.g. in response to a client cancellation) do not count. null disables the reset budget. Declaration public int? MaxPeerInitiatedIncompleteStreamResets { get; } Property Value Type Description int? Methods | Edit this page View Source Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?) Validates and constructs a ProxyResourceLimits snapshot. Every non-nullable bound must be strictly positive; every nullable bound, if supplied, must also be strictly positive (use null to disable rather than a sentinel number). Declaration public static ProxyResourceLimits Create(long maxHeaderLineBytes, int maxHeaderCount, long maxHeaderAggregateBytes, long? maxEncodedBodyBytes, long? maxDecodedBodyBytes, double? maxDecompressionRatio, int? maxConcurrentClients, int maxConcurrentStreamsPerConnection, int? maxPeerInitiatedIncompleteStreamResets, int maxOpenHeaderBlockFrames, TimeSpan maxOpenHeaderBlockDuration, bool connectionPoolingEnabled, int maxCachedConnectionsPerHost, int? maxCertificateCacheEntries) Parameters Type Name Description long maxHeaderLineBytes int maxHeaderCount long maxHeaderAggregateBytes long? maxEncodedBodyBytes long? maxDecodedBodyBytes double? maxDecompressionRatio int? maxConcurrentClients int maxConcurrentStreamsPerConnection int? maxPeerInitiatedIncompleteStreamResets int maxOpenHeaderBlockFrames TimeSpan maxOpenHeaderBlockDuration bool connectionPoolingEnabled int maxCachedConnectionsPerHost int? maxCertificateCacheEntries Returns Type Description ProxyResourceLimits Exceptions Type Condition ArgumentOutOfRangeException A supplied value is zero or negative. | Edit this page View Source WithCertificateCacheBounds(int?, int?) Returns a copy of this instance with MaxCertificateCacheEntries and MaxCertificateDiskCacheEntries replaced, leaving every other limit unchanged. Added instead of extending Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?) - which is public API already shipped with a fixed parameter list - so that adding the independent disk-cache bound could not be a breaking change for existing callers. Declaration public ProxyResourceLimits WithCertificateCacheBounds(int? maxCertificateCacheEntries, int? maxCertificateDiskCacheEntries) Parameters Type Name Description int? maxCertificateCacheEntries See MaxCertificateCacheEntries. null disables the bound. int? maxCertificateDiskCacheEntries See MaxCertificateDiskCacheEntries. null disables the bound. Returns Type Description ProxyResourceLimits Exceptions Type Condition ArgumentOutOfRangeException A supplied value is zero or negative. | Edit this page View Source WithMaxConcurrentStreamsPerConnection(int) Returns a copy with MaxConcurrentStreamsPerConnection replaced. Declaration public ProxyResourceLimits WithMaxConcurrentStreamsPerConnection(int maxConcurrentStreamsPerConnection) Parameters Type Name Description int maxConcurrentStreamsPerConnection Returns Type Description ProxyResourceLimits | Edit this page View Source WithMaxOriginHttp2ConnectionsPerAuthority(int) Returns a copy with MaxOriginHttp2ConnectionsPerAuthority replaced. Declaration public ProxyResourceLimits WithMaxOriginHttp2ConnectionsPerAuthority(int maxOriginHttp2ConnectionsPerAuthority) Parameters Type Name Description int maxOriginHttp2ConnectionsPerAuthority Returns Type Description ProxyResourceLimits" }, "api/Titanium.Web.Proxy.Options.ProxyTimeoutOptions.html": { "href": "api/Titanium.Web.Proxy.Options.ProxyTimeoutOptions.html", @@ -542,7 +542,7 @@ "api/Titanium.Web.Proxy.Options.html": { "href": "api/Titanium.Web.Proxy.Options.html", "title": "Namespace Titanium.Web.Proxy.Options | Titanium Web Proxy", - "summary": "Namespace Titanium.Web.Proxy.Options Classes ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. ProxyProfileSettings The full bundle of settings one ProxyProfile applies to a ProxyServer as a single atomic assignment via Profile. Deliberately a plain data bundle, not a type with behavior: applying it is ProxyServer's job (see Profile's setter), so this type has no back-reference and no side effects of its own, per the plan's \"Constraints on the policy layer\" section. ProxyResourceLimits Immutable, validated snapshot of the resource bounds a peer can make the proxy allocate: header shape, body/decompression budgets, concurrency and abuse-rate ceilings, and pool / certificate-cache sizing. Constructed only through Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?), which validates every field up front, so an invalid limit is a construction-time exception rather than a runtime surprise discovered mid-connection. A limit that can legitimately be turned off is typed as nullable with null meaning \"disabled\" - an explicit state - rather than overloading 0 or a negative number to mean the same thing. Limits that must always be enforced because disabling them would leave the proxy itself exploitable (the concurrent-stream cap, the open-header-block frame bound) are non-nullable and always validated to be strictly positive. This type has no back-reference to ProxyServer and no mutable state after construction: it is meant to be handed down to subsystems by value, not looked up through a service locator or an ambient static, so the dependency graph among consumers stays acyclic per the plan's \"Constraints on the policy layer\" section. ProxyTimeoutOptions Immutable, validated snapshot of every deadline the proxy enforces across a request's lifetime, expressed consistently as TimeSpan rather than the mixture of \"seconds as int\" properties this replaces (ConnectionTimeOutSeconds, ConnectTimeOutSeconds, ...). A deadline that can be legitimately unbounded is nullable, with null meaning \"no deadline\" as an explicit state rather than Zero or a magic sentinel duration. This type only holds values; it does not decide which deadline fired first when several are composed for one request. That is the responsibility of the per-request deadline registry described in the plan's \"Deadline composition\" section, introduced alongside the header-parsing deadlines in a later item so it can be exercised by a real caller instead of landing as unused scaffolding. ResolvedSessionPolicy Read-only snapshot combining ProxyResourceLimits and ProxyTimeoutOptions into the single object H1/H2/H3/WebSocket subsystems are handed, so runtime mutation of either half cannot produce inconsistent enforcement partway through a request that started under a different combination of the two. Per the plan's \"Constraints on the policy layer\" section, this type is deliberately inert: no back-pointer to ProxyServer, no service-locator lookup, no mutable fields, no static ambient accessor. Subsystems receive it as a constructor argument or method parameter only, so the dependency graph among consumers stays acyclic - the opposite of how ProxyServer itself is reached today. Per the plan's \"Two-phase policy resolution\" section, a single resolution per connection is not correct: SessionEventArgs.MaxBufferedBodyBytes is contractually settable from BeforeRequest, and HTTP/3 already reads the request body before BeforeRequest fires. This type does not itself perform either resolution phase - that is the responsibility of the call sites introduced in later hardening-plan items, once there is a real per-session override path to resolve against - but it is deliberately shaped so a caller can hold one instance at headers-complete time (ResourceLimits's framing/header-shape fields, which are never overridable) and, if a session lowers a body or streaming budget in BeforeRequest, build a second instance via Create(ProxyResourceLimits, ProxyTimeoutOptions) that shares the same Timeouts but substitutes a ProxyResourceLimits reflecting the override, rather than mutating the first instance in place. Enums PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. PolicyMode How a resource-bound policy family is applied once its numeric limit is breached, per the plan's \"Rollout, profiles and documentation\" section. Not every family supports every mode. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no Observe mode at all: an ambiguous chunk size or a conflicting length can only be forwarded (a desync) or rejected, so those call sites are unconditionally enforced and never consult a PolicyMode. ProxyPolicyModes exists for the families where a safe \"detect but let it through\" middle ground is actually possible. ProxyProfile The three shipped profiles, per the plan's \"Rollout, profiles and documentation\" section. Selecting a profile via Profile applies its ProxyProfileSettings atomically to resource limits, policy modes, TLS protocols, private-network blocking, admission caps, and deadline-second properties, so a caller can never observe a half-applied profile." + "summary": "Namespace Titanium.Web.Proxy.Options Classes ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. ProxyProfileSettings The full bundle of settings one ProxyProfile applies to a ProxyServer as a single atomic assignment via Profile. Deliberately a plain data bundle, not a type with behavior: applying it is ProxyServer's job (see Profile's setter), so this type has no back-reference and no side effects of its own, per the plan's \"Constraints on the policy layer\" section. ProxyResourceLimits Immutable, validated snapshot of the resource bounds a peer can make the proxy allocate: header shape, body/decompression budgets, concurrency and abuse-rate ceilings, and pool / certificate-cache sizing. Constructed only through Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?), which validates every field up front, so an invalid limit is a construction-time exception rather than a runtime surprise discovered mid-connection. A limit that can legitimately be turned off is typed as nullable with null meaning \"disabled\" - an explicit state - rather than overloading 0 or a negative number to mean the same thing. Limits that must always be enforced because disabling them would leave the proxy itself exploitable (the concurrent-stream cap, the open-header-block frame bound) are non-nullable and always validated to be strictly positive. This type has no back-reference to ProxyServer and no mutable state after construction: it is meant to be handed down to subsystems by value, not looked up through a service locator or an ambient static, so the dependency graph among consumers stays acyclic per the plan's \"Constraints on the policy layer\" section. ProxyTimeoutOptions Immutable, validated snapshot of every deadline the proxy enforces across a request's lifetime, expressed consistently as TimeSpan rather than the mixture of \"seconds as int\" properties this replaces (ConnectionTimeOutSeconds, ConnectTimeOutSeconds, ...). A deadline that can be legitimately unbounded is nullable, with null meaning \"no deadline\" as an explicit state rather than Zero or a magic sentinel duration. This type only holds values; it does not decide which deadline fired first when several are composed for one request. That is the responsibility of the per-request deadline registry described in the plan's \"Deadline composition\" section, introduced alongside the header-parsing deadlines in a later item so it can be exercised by a real caller instead of landing as unused scaffolding. ResolvedSessionPolicy Read-only snapshot combining ProxyResourceLimits and ProxyTimeoutOptions into the single object H1/H2/H3/WebSocket subsystems are handed, so runtime mutation of either half cannot produce inconsistent enforcement partway through a request that started under a different combination of the two. Per the plan's \"Constraints on the policy layer\" section, this type is deliberately inert: no back-pointer to ProxyServer, no service-locator lookup, no mutable fields, no static ambient accessor. Subsystems receive it as a constructor argument or method parameter only, so the dependency graph among consumers stays acyclic - the opposite of how ProxyServer itself is reached today. Per the plan's \"Two-phase policy resolution\" section, a single resolution per connection is not correct: SessionEventArgs.MaxBufferedBodyBytes is contractually settable from BeforeRequest, and HTTP/3 already reads the request body before BeforeRequest fires. This type does not itself perform either resolution phase - that is the responsibility of the call sites introduced in later hardening-plan items, once there is a real per-session override path to resolve against - but it is deliberately shaped so a caller can hold one instance at headers-complete time (ResourceLimits's framing/header-shape fields, which are never overridable) and, if a session lowers a body or streaming budget in BeforeRequest, build a second instance via Create(ProxyResourceLimits, ProxyTimeoutOptions) that shares the same Timeouts but substitutes a ProxyResourceLimits reflecting the override, rather than mutating the first instance in place. Enums PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. PolicyMode How a resource-bound policy family is applied once its numeric limit is breached, per the plan's \"Rollout, profiles and documentation\" section. Not every family supports every mode. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no Observe mode at all: an ambiguous chunk size or a conflicting length can only be forwarded (a desync) or rejected, so those call sites are unconditionally enforced and never consult a PolicyMode. ProxyPolicyModes exists for the families where a safe \"detect but let it through\" middle ground is actually possible. ProxyProfile The three shipped profiles, per the plan's \"Rollout, profiles and documentation\" section. Selecting a profile via Profile applies its ProxyProfileSettings atomically to resource limits, policy modes, TLS protocols, private-network blocking, admission caps, and deadline-second properties, so a caller can never observe a half-applied profile." }, "api/Titanium.Web.Proxy.ProxyLimits.html": { "href": "api/Titanium.Web.Proxy.ProxyLimits.html", diff --git a/docs/xrefmap.yml b/docs/xrefmap.yml index 0e0beb060..6d77c5e65 100644 --- a/docs/xrefmap.yml +++ b/docs/xrefmap.yml @@ -7994,6 +7994,12 @@ references: commentId: F:Titanium.Web.Proxy.Options.PolicyFamily.HeaderLimits fullName: Titanium.Web.Proxy.Options.PolicyFamily.HeaderLimits nameWithType: PolicyFamily.HeaderLimits +- uid: Titanium.Web.Proxy.Options.PolicyFamily.Http1ReplaySafety + name: Http1ReplaySafety + href: api/Titanium.Web.Proxy.Options.PolicyFamily.html#Titanium_Web_Proxy_Options_PolicyFamily_Http1ReplaySafety + commentId: F:Titanium.Web.Proxy.Options.PolicyFamily.Http1ReplaySafety + fullName: Titanium.Web.Proxy.Options.PolicyFamily.Http1ReplaySafety + nameWithType: PolicyFamily.Http1ReplaySafety - uid: Titanium.Web.Proxy.Options.PolicyFamily.Http2AbuseBudget name: Http2AbuseBudget href: api/Titanium.Web.Proxy.Options.PolicyFamily.html#Titanium_Web_Proxy_Options_PolicyFamily_Http2AbuseBudget @@ -8062,12 +8068,12 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Options.ProxyPolicyModes.AllowAmbiguousFraming nameWithType: ProxyPolicyModes.AllowAmbiguousFraming -- uid: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode) - name: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) - href: api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html#Titanium_Web_Proxy_Options_ProxyPolicyModes_Create_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_ - commentId: M:Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode) - fullName: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode) - nameWithType: ProxyPolicyModes.Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) +- uid: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode) + name: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) + href: api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html#Titanium_Web_Proxy_Options_ProxyPolicyModes_Create_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_ + commentId: M:Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode) + fullName: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode) + nameWithType: ProxyPolicyModes.Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) - uid: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create* name: Create href: api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html#Titanium_Web_Proxy_Options_ProxyPolicyModes_Create_ diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index 91c44ee94..36675dd65 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,7 +7,7 @@ latest enable false - 7.0.14 + 7.0.15 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index ff30179b5..680b9ba20 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -1673,8 +1673,8 @@ private SessionSnapshot CreateTunnelSnapshot(TunnelConnectSessionEventArgs e, Op string? requestBody = null; var needsBodyForTools = - (AutoResponder is { Enabled: true } && AutoResponder.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || - (MapRemote is { Enabled: true } && MapRemote.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || + (AutoResponder is { Enabled: true } && AutoResponder.HasEnabledGraphQlRule()) || + (MapRemote is { Enabled: true } && MapRemote.HasEnabledGraphQlRule()) || (Breakpoints is { Enabled: true } && !string.IsNullOrWhiteSpace(Breakpoints.GraphQlOperationName)); if (needsBodyForTools && e.HttpClient.Request.IsBodyRead) { diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index 48eb3c664..173e859df 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.14 + 7.0.15 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs b/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs index b689f482c..2b3ce523d 100644 --- a/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs @@ -10,6 +10,7 @@ namespace Titanium.Inspector.ViewModels; /// AutoResponder rules — evaluated before breakpoints. Optional Map Local file body. public sealed class AutoResponderViewModel : INotifyPropertyChanged { + private readonly object _rulesGate = new(); private bool _enabled; private AutoResponderRule? _selectedRule; @@ -29,6 +30,7 @@ public bool Enabled } } + /// UI-bound rule list. Mutate only via / / . public ObservableCollection Rules { get; } = new(); public AutoResponderRule? SelectedRule @@ -52,26 +54,53 @@ public AutoResponderRule? SelectedRule public void NotifyRulesChanged() => RulesChanged?.Invoke(this, EventArgs.Empty); + public void AddRule(AutoResponderRule rule) + { + lock (_rulesGate) + { + Rules.Add(rule); + } + } + + public bool RemoveRule(AutoResponderRule rule) + { + lock (_rulesGate) + { + return Rules.Remove(rule); + } + } + + public void ClearRules() + { + lock (_rulesGate) + { + Rules.Clear(); + } + } + public void LoadFromDtos(IEnumerable dtos) { - Rules.Clear(); - foreach (var dto in dtos) + lock (_rulesGate) { - Rules.Add(new AutoResponderRule + Rules.Clear(); + foreach (var dto in dtos) { - MatchUrl = dto.MatchUrl, - StatusCode = dto.StatusCode, - Body = dto.Body, - ContentType = string.IsNullOrEmpty(dto.ContentType) ? "text/plain" : dto.ContentType, - Enabled = dto.Enabled, - LocalFilePath = dto.LocalFilePath ?? string.Empty, - GraphQlOperationName = dto.GraphQlOperationName ?? string.Empty, - }); + Rules.Add(new AutoResponderRule + { + MatchUrl = dto.MatchUrl, + StatusCode = dto.StatusCode, + Body = dto.Body, + ContentType = string.IsNullOrEmpty(dto.ContentType) ? "text/plain" : dto.ContentType, + Enabled = dto.Enabled, + LocalFilePath = dto.LocalFilePath ?? string.Empty, + GraphQlOperationName = dto.GraphQlOperationName ?? string.Empty, + }); + } } } public List ToDtos() => - Rules.Select(r => new AutoResponderRuleDto + SnapshotRules().Select(r => new AutoResponderRuleDto { MatchUrl = r.MatchUrl, StatusCode = r.StatusCode, @@ -82,6 +111,20 @@ public List ToDtos() => GraphQlOperationName = string.IsNullOrWhiteSpace(r.GraphQlOperationName) ? null : r.GraphQlOperationName, }).ToList(); + /// True when an enabled rule filters by GraphQL operation (needs request body). + public bool HasEnabledGraphQlRule() + { + foreach (var r in SnapshotRules()) + { + if (r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName)) + { + return true; + } + } + + return false; + } + public bool TryMatch(string url, string? requestBody, out AutoResponderRule? matched) { matched = null; @@ -90,7 +133,8 @@ public bool TryMatch(string url, string? requestBody, out AutoResponderRule? mat return false; } - foreach (var rule in Rules) + // Snapshot: proxy OnBeforeRequest enumerates while the UI may Add/Remove. + foreach (var rule in SnapshotRules()) { if (!rule.Enabled) { @@ -114,6 +158,16 @@ public bool TryMatch(string url, string? requestBody, out AutoResponderRule? mat return false; } + private AutoResponderRule[] SnapshotRules() + { + lock (_rulesGate) + { + var arr = new AutoResponderRule[Rules.Count]; + Rules.CopyTo(arr, 0); + return arr; + } + } + public bool TryMatch(string url, out AutoResponderRule? matched) => TryMatch(url, requestBody: null, out matched); diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs index e2e2cc228..bfac3295b 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs @@ -446,7 +446,7 @@ private Task AddAutoResponderRuleAsync() return Task.CompletedTask; } - AutoResponder.Rules.Add(new AutoResponderRule + AutoResponder.AddRule(new AutoResponderRule { MatchUrl = AutoResponderMatch, StatusCode = status, @@ -468,7 +468,7 @@ private Task DeleteAutoResponderRuleAsync() return Task.CompletedTask; } - AutoResponder.Rules.Remove(AutoResponder.SelectedRule); + AutoResponder.RemoveRule(AutoResponder.SelectedRule); AutoResponder.SelectedRule = null; PersistAutoResponder(); StatusText = "AutoResponder rule deleted"; @@ -534,7 +534,7 @@ private async Task BrowseAutoResponderLocalFileAsync() } private Task AddMapRemoteRuleAsync() { - MapRemote.Rules.Add(new MapRemoteRule + MapRemote.AddRule(new MapRemoteRule { MatchUrl = MapRemoteMatch, TargetUrl = MapRemoteTarget, @@ -553,7 +553,7 @@ private Task DeleteMapRemoteRuleAsync() return Task.CompletedTask; } - MapRemote.Rules.Remove(MapRemote.SelectedRule); + MapRemote.RemoveRule(MapRemote.SelectedRule); MapRemote.SelectedRule = null; PersistMapRemote(); StatusText = "Map Remote rule deleted"; diff --git a/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs b/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs index dac24482d..2a621d96d 100644 --- a/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs @@ -9,6 +9,7 @@ namespace Titanium.Inspector.ViewModels; /// Map Remote rules — rewrite request URL before origin (after AutoResponder). public sealed class MapRemoteViewModel : INotifyPropertyChanged { + private readonly object _rulesGate = new(); private bool _enabled; private MapRemoteRule? _selectedRule; @@ -28,6 +29,7 @@ public bool Enabled } } + /// UI-bound rule list. Mutate only via / / . public ObservableCollection Rules { get; } = new(); public MapRemoteRule? SelectedRule @@ -51,23 +53,50 @@ public MapRemoteRule? SelectedRule public void NotifyRulesChanged() => RulesChanged?.Invoke(this, EventArgs.Empty); + public void AddRule(MapRemoteRule rule) + { + lock (_rulesGate) + { + Rules.Add(rule); + } + } + + public bool RemoveRule(MapRemoteRule rule) + { + lock (_rulesGate) + { + return Rules.Remove(rule); + } + } + + public void ClearRules() + { + lock (_rulesGate) + { + Rules.Clear(); + } + } + public void LoadFromDtos(IEnumerable dtos) { - Rules.Clear(); - foreach (var dto in dtos) + lock (_rulesGate) { - Rules.Add(new MapRemoteRule + Rules.Clear(); + foreach (var dto in dtos) { - MatchUrl = dto.MatchUrl, - TargetUrl = dto.TargetUrl, - Enabled = dto.Enabled, - GraphQlOperationName = dto.GraphQlOperationName ?? string.Empty, - }); + Rules.Add(new MapRemoteRule + { + MatchUrl = dto.MatchUrl, + TargetUrl = dto.TargetUrl, + Enabled = dto.Enabled, + GraphQlOperationName = dto.GraphQlOperationName ?? string.Empty, + }); + } } } public List ToDtos() => - Rules.Select(r => new MapRemoteRuleDto + SnapshotRules().Select(r => new MapRemoteRuleDto { MatchUrl = r.MatchUrl, TargetUrl = r.TargetUrl, @@ -75,6 +104,20 @@ public List ToDtos() => GraphQlOperationName = string.IsNullOrWhiteSpace(r.GraphQlOperationName) ? null : r.GraphQlOperationName, }).ToList(); + /// True when an enabled rule filters by GraphQL operation (needs request body). + public bool HasEnabledGraphQlRule() + { + foreach (var r in SnapshotRules()) + { + if (r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName)) + { + return true; + } + } + + return false; + } + public bool TryRewrite(string url, string? requestBody, out string? rewritten, out MapRemoteRule? matched) { rewritten = null; @@ -84,7 +127,8 @@ public bool TryRewrite(string url, string? requestBody, out string? rewritten, o return false; } - foreach (var rule in Rules) + // Snapshot: proxy OnBeforeRequest enumerates while the UI may Add/Remove. + foreach (var rule in SnapshotRules()) { if (!rule.Enabled || string.IsNullOrWhiteSpace(rule.TargetUrl)) { @@ -113,6 +157,16 @@ public bool TryRewrite(string url, string? requestBody, out string? rewritten, o return false; } + private MapRemoteRule[] SnapshotRules() + { + lock (_rulesGate) + { + var arr = new MapRemoteRule[Rules.Count]; + Rules.CopyTo(arr, 0); + return arr; + } + } + public bool TryRewrite(string url, out string? rewritten, out MapRemoteRule? matched) => TryRewrite(url, requestBody: null, out rewritten, out matched); diff --git a/src/Titanium.Plus/Titanium.Plus.csproj b/src/Titanium.Plus/Titanium.Plus.csproj index af61dac66..32608ede2 100644 --- a/src/Titanium.Plus/Titanium.Plus.csproj +++ b/src/Titanium.Plus/Titanium.Plus.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.14 + 7.0.15 Jehonathan Thomas Titanium Web Proxy Plus advanced features plugin (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj index e2f67dd3e..af2705780 100644 --- a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj +++ b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.14 + 7.0.15 Jehonathan Thomas Shared contracts for Titanium Web Proxy routing, clusters, middleware, and plugins. MIT diff --git a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj index 70630d967..c3a4b96eb 100644 --- a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj +++ b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.14 + 7.0.15 Jehonathan Thomas YAML/JSON configuration binding for Titanium Web Proxy CLI and reverse-proxy documents. MIT diff --git a/src/Titanium.Web.Proxy/Diagnostics/ProxyMetrics.cs b/src/Titanium.Web.Proxy/Diagnostics/ProxyMetrics.cs index ca3f11f8b..f52df0639 100644 --- a/src/Titanium.Web.Proxy/Diagnostics/ProxyMetrics.cs +++ b/src/Titanium.Web.Proxy/Diagnostics/ProxyMetrics.cs @@ -208,4 +208,24 @@ public static void Http2ProbeCompleted(double durationMs) => Http2ProbeDurationMs.Record(durationMs); public static void Http2CapabilityMismatch() => Http2CapabilityMismatches.Add(1); + + private static readonly Histogram Http2FrameWriterPending = + Meter.CreateHistogram("twp.http2.frame_writer.pending_frames", "{frame}", + "Frames queued on an HTTP/2 frame writer when a sample is recorded (enqueue high-water crossings)."); + + private static readonly Counter Http2FrameWriterHighWater = + Meter.CreateCounter("twp.http2.frame_writer.high_water_crossings", "{crossing}", + "Times an HTTP/2 frame-writer pending depth crossed a watermark threshold."); + + /// + /// Records a frame-writer queue-depth sample when a high-water threshold is crossed. + /// Call sites sample sparsely (threshold crossings only) so the hot enqueue path stays cheap. + /// + public static void Http2FrameWriterDepthSample(long pendingFrames, string threshold) => + Http2FrameWriterPending.Record(pendingFrames, + new KeyValuePair("threshold", threshold)); + + public static void Http2FrameWriterHighWaterCrossed(string threshold) => + Http2FrameWriterHighWater.Add(1, + new KeyValuePair("threshold", threshold)); } diff --git a/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs b/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs index 5d30d826c..9dbaf57d0 100644 --- a/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs +++ b/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net; @@ -21,11 +22,52 @@ namespace Titanium.Web.Proxy; +/// +/// Signals that H1 terminate-lite cannot finish this exchange (typically a 1xx interim response) +/// and the caller should fall through to the full session path without treating it as a failure. +/// +internal sealed class H1TerminateLiteFallbackException : Exception +{ + public H1TerminateLiteFallbackException(string message) : base(message) + { + } +} + public partial class ProxyServer { private static readonly Lazy H1TerminateLiteProcessId = new(() => 0); private static readonly object H1TerminateLiteMiddlewareSession = new(); + /// + /// Await-safe shell pool for terminate-lite. ThreadStatic is unsafe here: a second Rent on the + /// same worker can while the first request + /// still awaits across the shared Response (HTTP/0.0 0 / spliced status lines under load). + /// + private static readonly ConcurrentBag H1TerminateLiteClients = new(); + private const int H1TerminateLiteClientPoolCap = 256; + + private static HttpWebClient RentH1TerminateLiteClient(Request request) + { + if (H1TerminateLiteClients.TryTake(out var client)) + { + client.RebindForTerminateLite(request); + return client; + } + + client = new HttpWebClient(null, request, H1TerminateLiteProcessId); + // Force Response materialization once so Rebind can ResetForKeepAlive. + _ = client.Response; + return client; + } + + private static void ReleaseH1TerminateLiteClient(HttpWebClient client) + { + // Drop the origin socket reference so TcpConnectionFactory.Release remains the sole owner. + client.RebindForTerminateLite(client.Request); + if (H1TerminateLiteClients.Count < H1TerminateLiteClientPoolCap) + H1TerminateLiteClients.Add(client); + } + /// /// Interception-off transparent reverse with fixed : /// bodiless GET/HEAD without allocating (H3→H1 session-lite analogue). @@ -148,7 +190,9 @@ internal bool CanUseH1TerminateLite(ProxyEndPoint endPoint, Request request, boo endPoint.CachedHttp11PoolIsHttps = isHttps; } - var http = new HttpWebClient(null, request, H1TerminateLiteProcessId); + var http = RentH1TerminateLiteClient(request); + try + { http.SetConnection(connection); await http.SendRequest(false, isTransparent: true, OriginHttpVersionPolicy, cancellationToken); await http.ReceiveResponse(cancellationToken); @@ -156,8 +200,10 @@ internal bool CanUseH1TerminateLite(ProxyEndPoint endPoint, Request request, boo var response = http.Response; if (response.StatusCode is >= 100 and <= 199) { - // 1xx needs the full session path's interim loop — close origin and signal fallback. - throw new InvalidOperationException("H1 terminate lite does not handle interim 1xx responses."); + // Full session path has the interim 1xx loop; keep the origin connection for it. + closeConnection = false; + throw new H1TerminateLiteFallbackException( + "H1 terminate lite does not handle interim 1xx responses."); } try @@ -250,6 +296,11 @@ await connection.Stream.CopyBodyAsync(response, false, clientStream, Transformat // Client Connection: close (NC) → stop accept-loop KA (origin may stay pooled). return response.KeepAlive && !clientRequestedClose; + } + finally + { + ReleaseH1TerminateLiteClient(http); + } } catch (RetryableServerConnectionException) { @@ -351,7 +402,8 @@ await connection.Stream.CopyBodyAsync(response, false, clientStream, Transformat var response = http.Response; if (response.StatusCode is >= 100 and <= 199) { - throw new InvalidOperationException( + closeConnection = false; + throw new H1TerminateLiteFallbackException( "H1 terminate MITM lite does not handle interim 1xx responses."); } diff --git a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs index 02285ccc7..db8d22a63 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs @@ -552,6 +552,21 @@ private async Task EstablishHttp2OriginTcpConnectionAsync(S PrepareRequestForOrigin(request); + // Expect: 100-continue — H1 clients wait for 100 before sending the body. The h2 origin + // path sends HEADERS then immediately pumps the body, so without a synthetic 100 a + // strict client deadlocks. Mirror RequestHandler: synthetic 100 when CompatibilityMode + // is on or when Enable100ContinueBehaviour is off (the default). Strip Expect so the + // origin does not also wait. + if (request.ExpectContinue && !request.IsBodyRead + && (CompatibilityMode100Continue || !Enable100ContinueBehaviour)) + { + var continueBuilder = new HeaderBuilder(); + continueBuilder.WriteResponseLine(request.HttpVersion, 100, "Continue"); + continueBuilder.WriteHeaders(new HeaderCollection()); + await clientStream.WriteHeadersAsync(continueBuilder, cancellationToken); + request.Headers.RemoveHeader(KnownHeaders.Expect); + } + // Http2OriginConnection.SendAsync performs the whole request-send + response-receive round trip // in one call rather than exposing separate phases, so - unlike the HTTP/1.1 and h2-to-HTTP/1.1 // paths - RequestSentAt and ResponseHeadersReceivedAt below are necessarily approximated as the @@ -720,10 +735,22 @@ await clientStream.WriteBodyAsync(fastBody, response.IsChunked, MaybeInjectClientAltSvc(args); + // Capture before BeforeResponse: a user replacement clears StreamBodyWriter on the new + // Response object, and the origin concurrency gate / lease would never be released. + var originStreamBody = response.StreamBodyWriter; + if (!response.Locked) await OnBeforeResponse(args); response = args.HttpClient.Response; + if (!ReferenceEquals(response, exchange.Response) && originStreamBody != null) + { + exchange.Response.StreamBodyWriter = null; + // Drain and release origin resources; the replacement body is unrelated. + await originStreamBody(Stream.Null, cancellationToken); + originStreamBody = null; + } + if (response.Locked) { // The user replaced the response inside BeforeResponse - write it out generically. There is no diff --git a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs index 9c2322e52..32e88f448 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs @@ -458,13 +458,17 @@ await sessionArgs.HttpClient.SendRequest(Enable100ContinueBehaviour, true, if (streamRequestBody && requestBodyChannel is { } bodyChannel) { var bodyWriter = new Helpers.BodyStreamWriter(connection.Stream, request.IsChunked); + long written = 0; await foreach (var chunk in bodyChannel.Reader.ReadAllAsync(cancellationToken)) { try { if (chunk.Length > 0) + { + written += chunk.Length; await bodyWriter.WriteAsync(chunk.Buffer.AsMemory(0, chunk.Length), cancellationToken); + } } finally { @@ -472,6 +476,18 @@ await bodyWriter.WriteAsync(chunk.Buffer.AsMemory(0, chunk.Length), } } + // Declared Content-Length must match bytes actually streamed; a short body would leave + // the H1 origin waiting and desync the pooled keep-alive socket. + if (!request.IsChunked && request.ContentLength >= 0 && written != request.ContentLength) + { + sessionArgs.HttpClient.CloseServerConnection = true; + Http2Helper.QueueRstStreamFrame(connectionState, clientStream, streamId, + Http2ErrorCode.ProtocolError); + throw new ProxyHttpException( + $"HTTP/2→HTTP/1.1 streamed body length {written} did not match Content-Length {request.ContentLength}.", + null, sessionArgs); + } + await bodyWriter.CompleteAsync( request.HasTrailingHeaders ? request.TrailingHeaders : null, cancellationToken); request.IsBodyReceived = true; @@ -721,7 +737,7 @@ await framed.CopyFromAsync(async (dest, token) => } await Http2Helper.EmitSyntheticResponseAsync(sessionArgs, streamId, connectionState, clientStream, - cancellationToken, wireBody: eagerWireBody is { } bytes ? bytes.AsMemory() : null); + cancellationToken, wireBody: eagerWireBody is { } bytes ? (ReadOnlyMemory?)bytes.AsMemory() : null); if (restoreResponseVersionAfterEmit) sessionArgs.HttpClient.Response.HttpVersion = HttpHeader.Version11; @@ -765,16 +781,9 @@ await Http2Helper.EmitSyntheticResponseAsync(sessionArgs, streamId, connectionSt // round trip failed (e.g. the origin dropped the connection mid-body) - an // already-sent HEADERS frame cannot be replaced, so the best this can do is tell the // client the stream ended abnormally instead of silently truncating the body. - await connectionState.ClientWriteLock.WaitAsync(CancellationToken.None); - try - { - await Http2Helper.SendRstStreamAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.InternalError, clientStream); - } - finally - { - connectionState.ClientWriteLock.Release(); - } + // Queue through the FIFO so RST cannot overtake still-queued HEADERS/DATA. + Http2Helper.QueueRstStreamFrame(connectionState, clientStream, streamId, + Http2ErrorCode.InternalError); } } catch (Exception clientErrorFrameEx) @@ -854,6 +863,8 @@ await Http2Helper.FinalizeStreamAsync(finalStreamState, // Build and send the WebSocket upgrade request toward the h1 origin. var request = sessionArgs.HttpClient.Request; + if (HttpHeaderHygiene.ContainsForbiddenDelimiter(request.RequestUri.PathAndQuery)) + throw new InvalidOperationException("Request path contains CR, LF, or NUL."); var wsKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); var sb = new StringBuilder(); sb.Append($"GET {request.RequestUri.PathAndQuery} HTTP/1.1\r\n"); @@ -862,6 +873,8 @@ await Http2Helper.FinalizeStreamAsync(finalStreamState, var authorityStr = request.Host ?? (request.Authority.Length > 0 ? request.Authority.GetString() : null) ?? $"{remoteHostName}:{remotePort}"; + if (HttpHeaderHygiene.ContainsForbiddenDelimiter(authorityStr)) + throw new InvalidOperationException("Authority contains CR, LF, or NUL."); sb.Append($"Host: {authorityStr}\r\n"); sb.Append("Upgrade: websocket\r\n"); sb.Append("Connection: Upgrade\r\n"); @@ -878,6 +891,7 @@ await Http2Helper.FinalizeStreamAsync(finalStreamState, lname == "connection" || lname == "sec-websocket-key" || lname == "sec-websocket-version") continue; + HttpHeaderHygiene.ThrowIfForbidden(header.Name, header.Value); sb.Append($"{header.Name}: {header.Value}\r\n"); } @@ -1040,16 +1054,8 @@ await Http2Helper.EmitSyntheticResponseAsync(sessionArgs, ctx.StreamId, } else { - await ctx.ConnectionState.ClientWriteLock.WaitAsync(CancellationToken.None); - try - { - await Http2Helper.SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - ctx.StreamId, Http2ErrorCode.InternalError, ctx.ClientStream); - } - finally - { - ctx.ConnectionState.ClientWriteLock.Release(); - } + Http2Helper.QueueRstStreamFrame(ctx.ConnectionState, ctx.ClientStream, + ctx.StreamId, Http2ErrorCode.InternalError); } } catch (Exception rstEx) diff --git a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp3BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp3BridgeHandler.cs index b5ebf358d..6ffd20680 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp3BridgeHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp3BridgeHandler.cs @@ -14,6 +14,7 @@ using Titanium.Web.Proxy.Http; using Titanium.Web.Proxy.Http2; using Titanium.Web.Proxy.Http3; +using Titanium.Web.Proxy.Http3.Qpack; using Titanium.Web.Proxy.Logging; using Titanium.Web.Proxy.Models; using Titanium.Web.Proxy.StreamExtended.Network; @@ -270,6 +271,7 @@ private static void ConsolidateCookieHeaders(HeaderCollection headers) Func? copyRequestBody = null; if (requestBodyChannel != null && !sessionArgs.HttpClient.Request.IsBodyRead) { + var request = sessionArgs.HttpClient.Request; copyRequestBody = async (originStream, ct) => { await foreach (var chunk in requestBodyChannel.Reader.ReadAllAsync(ct)) @@ -286,7 +288,15 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, } } - sessionArgs.HttpClient.Request.IsBodyReceived = true; + if (request.HasTrailingHeaders) + { + var trailerBlock = QpackEncoder.Encode( + request.TrailingHeaders.Select(h => (h.Name, h.Value))); + await Http3Frame.WriteAsync(originStream, Http3FrameType.Headers, trailerBlock, ct, + completeWrites: true); + } + + request.IsBodyReceived = true; }; } @@ -314,7 +324,24 @@ await Http2Helper.EmitInterimResponseAsync( sessionArgs.HttpClient.Response.SetOriginalHeaders(); if (!sessionArgs.IsFastPath && !sessionArgs.HttpClient.Response.Locked) + { + var responseBeforeHandler = sessionArgs.HttpClient.Response; await OnBeforeResponse(sessionArgs); + // If BeforeResponse replaced the response, the QUIC StreamBodyWriter never runs — + // drain it so the origin stream/lease is released (same shape as H1→H2). + if (!ReferenceEquals(responseBeforeHandler, sessionArgs.HttpClient.Response) + && responseBeforeHandler.StreamBodyWriter != null) + { + try + { + await responseBeforeHandler.StreamBodyWriter(Stream.Null, cancellationToken); + } + catch + { + // Best-effort release of the QUIC stream/lease; emit path uses the new response. + } + } + } var response = sessionArgs.HttpClient.Response; @@ -375,18 +402,10 @@ await Http2Helper.EmitSyntheticResponseAsync( else { // Headers (and possibly part of the body) already reached the client before the - // origin round trip failed. Send RST_STREAM to signal abnormal termination. - await connectionState.ClientWriteLock.WaitAsync(CancellationToken.None); - try - { - await Http2Helper.SendRstStreamAsync( - new Http2FrameHeader(), new byte[9], - streamId, Http2ErrorCode.InternalError, clientStream); - } - finally - { - connectionState.ClientWriteLock.Release(); - } + // origin round trip failed. Queue RST through the FIFO so it cannot overtake + // still-queued HEADERS/DATA. + Http2Helper.QueueRstStreamFrame(connectionState, clientStream, streamId, + Http2ErrorCode.InternalError); } } catch @@ -404,6 +423,15 @@ await Http2Helper.SendRstStreamAsync( // concurrent RST_STREAM / GOAWAY finalization. if (connectionState.TryTakeStream(streamId, out var finalStreamState)) { + // Drain any leftover rented body buffers if the copy task never ran. + var bodyCh = finalStreamState.InboundRequestBodyChannel; + if (bodyCh != null) + { + bodyCh.Writer.TryComplete(); + while (bodyCh.Reader.TryRead(out var leftover)) + ArrayPool.Shared.Return(leftover.Buffer); + } + connectionState.ClientSendFlow.RemoveStream(streamId); connectionState.ServerSendFlow.RemoveStream(streamId); await Http2Helper.FinalizeStreamAsync( diff --git a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs index 2d13b7e88..394c49d2f 100644 --- a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs @@ -165,12 +165,19 @@ public partial class ProxyServer } preparedRequest.SetOriginalHeaders(); - var keepClient = await ForwardH1TerminateLiteAsync( - (TransparentBaseProxyEndPoint)endPoint, clientStream, preparedRequest, - cancellationToken); - if (!keepClient) - return; - continue; + try + { + var keepClient = await ForwardH1TerminateLiteAsync( + (TransparentBaseProxyEndPoint)endPoint, clientStream, preparedRequest, + cancellationToken); + if (!keepClient) + return; + continue; + } + catch (H1TerminateLiteFallbackException) + { + // 1xx (e.g. 103 Early Hints): fall through to the full session path. + } } // Gate failed after headers (e.g. body) — fall through with prepared request. @@ -365,6 +372,12 @@ public partial class ProxyServer return; } + // Route transforms (RequestHeaderSet / PathPrefix / …) must run while the + // Mitm COW baseline is still armed so append-only sets stay on terminate-lite. + // Taking the baseline before TryApply made MutationCount diverge and forced + // the full session path (~0.75× CLI reverse for intercept-http1). + Routing.ReverseProxySessionDispatch.TryApply(this, args); + var requestHeaderRelayBaseline = request.Headers.TakeMitmRelayBaseline(); // Total per-request deadline starts after BeforeRequest so session overrides apply. @@ -373,9 +386,6 @@ public partial class ProxyServer var requestToken = requestDeadline.Token; args.OperationCancellationToken = requestToken; - // Per-request route → destination (no-op when ReverseProxy routes unset). - Routing.ReverseProxySessionDispatch.TryApply(this, args); - try { if (!args.IsTransparent && !args.IsSocks) @@ -488,14 +498,21 @@ public partial class ProxyServer && request.Authority.Equals(capturedRequestAuthority) && !args.HttpClient.Response.Locked) { - var keepClient = await ForwardH1TerminateSessionLiteAsync( - mitmTerminateEp, clientStream, args, requestToken); - args.IsClientResponseCommitted = true; - if (!keepClient) - return; + try + { + var keepClient = await ForwardH1TerminateSessionLiteAsync( + mitmTerminateEp, clientStream, args, requestToken); + args.IsClientResponseCommitted = true; + if (!keepClient) + return; - allowMitmUnchangedRecycle = true; - continue; + allowMitmUnchangedRecycle = true; + continue; + } + catch (H1TerminateLiteFallbackException) + { + // 1xx: fall through to the full session response path below. + } } // If prefetch task is available. diff --git a/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs b/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs index 0aefa70b2..467530f70 100644 --- a/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs +++ b/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs @@ -15,7 +15,8 @@ internal class HeaderBuilder private readonly MemoryStream stream = new(256); #if DEBUG - private bool inUse; + // Directly constructed builders (never pooled) are always live; only Return() clears this. + private bool inUse = true; #endif /// Rents a thread-local builder (cleared). Caller must it. @@ -161,6 +162,7 @@ private void WriteHeaderMaybeHostOverride(HttpHeader header, string? hostHeaderO public void WriteHeader(HttpHeader header) { + HttpHeaderHygiene.ThrowIfForbidden(header.NameData, header.ValueData); // NameData/ValueData are already ISO-8859-1 bytes — do not force string + GetBytes. Write(header.NameData); WriteAscii(": "); diff --git a/src/Titanium.Web.Proxy/Http/HeaderCollection.cs b/src/Titanium.Web.Proxy/Http/HeaderCollection.cs index 2c090b6ff..37d00522a 100644 --- a/src/Titanium.Web.Proxy/Http/HeaderCollection.cs +++ b/src/Titanium.Web.Proxy/Http/HeaderCollection.cs @@ -16,9 +16,27 @@ public class HeaderCollection : IEnumerable { private Dictionary headers; - private Dictionary> nonUniqueHeaders; + /// + /// Lazy: almost all GET/HEAD exchanges use only unique headers. Allocated on first + /// Cookie/Set-Cookie-style duplicate name (see ). + /// + private Dictionary>? nonUniqueHeaders; + + private Dictionary>? nonUniqueHeadersReadOnly; + + private static readonly ReadOnlyDictionary> EmptyNonUniqueHeadersView = + new(new Dictionary>(0, StringComparer.OrdinalIgnoreCase)); + + private void EnsureNonUniqueMaps() + { + if (nonUniqueHeaders is not null) + return; - private Dictionary> nonUniqueHeadersReadOnly; + nonUniqueHeaders = new Dictionary>(2, StringComparer.OrdinalIgnoreCase); + nonUniqueHeadersReadOnly = + new Dictionary>(2, StringComparer.OrdinalIgnoreCase); + nonUniqueHeadersView = null; + } /// /// Monotonic counter bumped on every mutating API (, , @@ -99,7 +117,7 @@ internal void TakeContentsFrom(HeaderCollection source) if (ReferenceEquals(this, source)) return; - if (headers.Count != 0 || nonUniqueHeaders.Count != 0) + if (headers.Count != 0 || (nonUniqueHeaders is { Count: > 0 })) { foreach (var header in source) AddHeader(header); @@ -149,18 +167,25 @@ private void EnsureMitmRelayCowSnapshot() foreach (var kv in headers) unique[kv.Key] = kv.Value.Value; - var nonUnique = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var kv in nonUniqueHeaders) + Dictionary>? nonUnique = null; + var nonUniqueNames = 0; + if (nonUniqueHeaders is { Count: > 0 }) { - var values = new List(kv.Value.Count); - foreach (var h in kv.Value) - values.Add(h.Value); - nonUnique[kv.Key] = values; + nonUnique = new Dictionary>(nonUniqueHeaders.Count, StringComparer.OrdinalIgnoreCase); + foreach (var kv in nonUniqueHeaders) + { + var values = new List(kv.Value.Count); + foreach (var h in kv.Value) + values.Add(h.Value); + nonUnique[kv.Key] = values; + } + + nonUniqueNames = nonUniqueHeaders.Count; } _mitmRelayCowUnique = unique; _mitmRelayCowNonUnique = nonUnique; - _mitmRelayCowNonUniqueNames = nonUniqueHeaders.Count; + _mitmRelayCowNonUniqueNames = nonUniqueNames; _mitmRelayAppends = default; _mitmRelayAppendDirty = false; } @@ -170,12 +195,9 @@ private void EnsureMitmRelayCowSnapshot() /// public HeaderCollection() { - // Probe GETs / H2 Lite carry a handful of unique headers; keep the three maps small - // so empty Request/Response shells (before TakeContentsFrom) cost less per stream. + // Probe GETs / H2 Lite carry a handful of unique headers; keep the unique map small. + // Non-unique maps stay null until a duplicate name is added (Cookie / Set-Cookie / …). headers = new Dictionary(8, StringComparer.OrdinalIgnoreCase); - nonUniqueHeaders = new Dictionary>(2, StringComparer.OrdinalIgnoreCase); - nonUniqueHeadersReadOnly = - new Dictionary>(2, StringComparer.OrdinalIgnoreCase); } private ReadOnlyDictionary? headersView; @@ -192,7 +214,10 @@ public HeaderCollection() /// Add/Clear storage that still belongs to this collection. /// public ReadOnlyDictionary> NonUniqueHeaders => - nonUniqueHeadersView ??= new ReadOnlyDictionary>(nonUniqueHeadersReadOnly); + nonUniqueHeaders is null + ? EmptyNonUniqueHeadersView + : nonUniqueHeadersView ??= + new ReadOnlyDictionary>(nonUniqueHeadersReadOnly!); /// /// Returns an enumerator that iterates through the collection. @@ -243,11 +268,15 @@ public struct Enumerator : IEnumerator private List.Enumerator nonUniqueInnerEnumerator; private bool doneWithUnique; private bool hasInnerEnumerator; + private readonly bool hasNonUnique; internal Enumerator(HeaderCollection collection) { uniqueEnumerator = collection.headers.Values.GetEnumerator(); - nonUniqueOuterEnumerator = collection.nonUniqueHeaders.Values.GetEnumerator(); + hasNonUnique = collection.nonUniqueHeaders is { Count: > 0 }; + nonUniqueOuterEnumerator = hasNonUnique + ? collection.nonUniqueHeaders!.Values.GetEnumerator() + : default; nonUniqueInnerEnumerator = default; doneWithUnique = false; hasInnerEnumerator = false; @@ -271,6 +300,12 @@ public bool MoveNext() doneWithUnique = true; } + if (!hasNonUnique) + { + Current = null!; + return false; + } + while (true) { if (hasInnerEnumerator) @@ -312,7 +347,8 @@ public void Dispose() /// public bool HeaderExists(string name) { - return headers.ContainsKey(name) || nonUniqueHeaders.ContainsKey(name); + return headers.ContainsKey(name) || + (nonUniqueHeaders is not null && nonUniqueHeaders.ContainsKey(name)); } /// @@ -329,7 +365,7 @@ public bool HeaderExists(string name) header }; - if (nonUniqueHeaders.TryGetValue(name, out var nonUnique)) + if (nonUniqueHeaders is not null && nonUniqueHeaders.TryGetValue(name, out var nonUnique)) return new List(nonUnique); return null; @@ -339,7 +375,7 @@ public bool HeaderExists(string name) { if (headers.TryGetValue(name, out var header)) return header; - if (nonUniqueHeaders.TryGetValue(name, out var h)) return h.FirstOrDefault(); + if (nonUniqueHeaders is not null && nonUniqueHeaders.TryGetValue(name, out var h)) return h.FirstOrDefault(); return null; } @@ -348,7 +384,7 @@ public bool HeaderExists(string name) { if (headers.TryGetValue(name.String, out var header)) return header; - if (nonUniqueHeaders.TryGetValue(name.String, out var h)) return h.FirstOrDefault(); + if (nonUniqueHeaders is not null && nonUniqueHeaders.TryGetValue(name.String, out var h)) return h.FirstOrDefault(); return null; } @@ -377,14 +413,18 @@ public List GetAllHeaders() // headers.Count + nonUniqueHeaders.Count undercounts when any non-unique entry has more than one // value, but it is still a better starting capacity than the default (0), and List grows from // there exactly as it would have without this hint. - var result = new List(headers.Count + nonUniqueHeaders.Count); + var nonUniqueCount = nonUniqueHeaders?.Count ?? 0; + var result = new List(headers.Count + nonUniqueCount); foreach (var header in headers.Values) result.Add(header); - foreach (var list in nonUniqueHeaders.Values) + if (nonUniqueHeaders is not null) { - foreach (var header in list) - result.Add(header); + foreach (var list in nonUniqueHeaders.Values) + { + foreach (var header in list) + result.Add(header); + } } return result; @@ -418,7 +458,7 @@ public void AddHeader(HttpHeader newHeader) { if (_mitmRelayCowArmed && _mitmRelayCowUnique is null && !_mitmRelayAppendDirty && !headers.ContainsKey(newHeader.Name) - && !nonUniqueHeaders.ContainsKey(newHeader.Name) + && (nonUniqueHeaders is null || !nonUniqueHeaders.ContainsKey(newHeader.Name)) && _mitmRelayAppends.Count < Helpers.MitmCompressedRelayHelper.DefaultMaxAppendHeaders) { // Pure append of a new unique name: log for compressed relay without cloning wire headers. @@ -431,7 +471,7 @@ public void AddHeader(HttpHeader newHeader) EnsureMitmRelayCowSnapshot(); MutationCount++; // if header exist in non-unique header collection add it there - if (nonUniqueHeaders.TryGetValue(newHeader.Name, out var list)) + if (nonUniqueHeaders is not null && nonUniqueHeaders.TryGetValue(newHeader.Name, out var list)) { list.Add(newHeader); return; @@ -447,8 +487,9 @@ public void AddHeader(HttpHeader newHeader) existing, newHeader }; - nonUniqueHeaders.Add(newHeader.Name, moved); - nonUniqueHeadersReadOnly.Add(newHeader.Name, new ReadOnlyCollection(moved)); + EnsureNonUniqueMaps(); + nonUniqueHeaders!.Add(newHeader.Name, moved); + nonUniqueHeadersReadOnly!.Add(newHeader.Name, new ReadOnlyCollection(moved)); } else { @@ -511,9 +552,9 @@ public bool RemoveHeader(string headerName) var result = headers.Remove(headerName); // do not convert to '||' expression to avoid lazy evaluation - if (nonUniqueHeaders.Remove(headerName)) + if (nonUniqueHeaders is not null && nonUniqueHeaders.Remove(headerName)) { - nonUniqueHeadersReadOnly.Remove(headerName); + nonUniqueHeadersReadOnly!.Remove(headerName); result = true; } @@ -535,9 +576,9 @@ public bool RemoveHeader(KnownHeader headerName) var result = headers.Remove(headerName.String); // do not convert to '||' expression to avoid lazy evaluation - if (nonUniqueHeaders.Remove(headerName.String)) + if (nonUniqueHeaders is not null && nonUniqueHeaders.Remove(headerName.String)) { - nonUniqueHeadersReadOnly.Remove(headerName.String); + nonUniqueHeadersReadOnly!.Remove(headerName.String); result = true; } @@ -564,7 +605,8 @@ public bool RemoveHeader(HttpHeader header) return false; } - if (nonUniqueHeaders.TryGetValue(header.Name, out var matchingHeaders) && + if (nonUniqueHeaders is not null && + nonUniqueHeaders.TryGetValue(header.Name, out var matchingHeaders) && matchingHeaders.RemoveAll(x => x.Equals(header)) > 0) { MutationCount++; @@ -580,11 +622,11 @@ public bool RemoveHeader(HttpHeader header) public void Clear() { EnsureMitmRelayCowSnapshot(); - if (headers.Count > 0 || nonUniqueHeaders.Count > 0) + if (headers.Count > 0 || (nonUniqueHeaders is { Count: > 0 })) MutationCount++; headers.Clear(); - nonUniqueHeaders.Clear(); - nonUniqueHeadersReadOnly.Clear(); + nonUniqueHeaders?.Clear(); + nonUniqueHeadersReadOnly?.Clear(); } /// @@ -594,8 +636,8 @@ public void Clear() internal void ResetForDecodeScratch() { headers.Clear(); - nonUniqueHeaders.Clear(); - nonUniqueHeadersReadOnly.Clear(); + nonUniqueHeaders?.Clear(); + nonUniqueHeadersReadOnly?.Clear(); MutationCount = 0; _mitmRelayCowArmed = false; _mitmRelayCowUnique = null; @@ -625,7 +667,7 @@ internal void NormalizeNamesToLowercaseAscii() if (!needsRename) return; - var renamed = new List(headers.Count + nonUniqueHeaders.Count); + var renamed = new List(headers.Count + (nonUniqueHeaders?.Count ?? 0)); foreach (var header in this) { var nameData = header.NameData; diff --git a/src/Titanium.Web.Proxy/Http/HttpHeaderHygiene.cs b/src/Titanium.Web.Proxy/Http/HttpHeaderHygiene.cs new file mode 100644 index 000000000..dabeea3ec --- /dev/null +++ b/src/Titanium.Web.Proxy/Http/HttpHeaderHygiene.cs @@ -0,0 +1,49 @@ +using System; +using System.Buffers; +using System.Runtime.CompilerServices; +using Titanium.Web.Proxy.Models; + +namespace Titanium.Web.Proxy.Http; + +/// +/// Rejects CR / LF / NUL in HTTP field names and values (RFC 9110 §5.5 / RFC 9113 §8.2.1). +/// Used wherever a value may be written onto an HTTP/1.1 wire (HeaderBuilder, bridges). +/// +internal static class HttpHeaderHygiene +{ + private static readonly SearchValues ForbiddenBytes = SearchValues.Create("\0\r\n"u8); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ContainsForbiddenDelimiter(ReadOnlySpan span) => + span.IndexOfAny(ForbiddenBytes) >= 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ContainsForbiddenDelimiter(string? value) + { + if (string.IsNullOrEmpty(value)) + return false; + foreach (var c in value) + { + if (c is '\0' or '\r' or '\n') + return true; + } + + return false; + } + + public static void ThrowIfForbidden(ByteString name, ByteString value) + { + if (ContainsForbiddenDelimiter(name.Span)) + throw new InvalidOperationException("HTTP header name contains CR, LF, or NUL."); + if (ContainsForbiddenDelimiter(value.Span)) + throw new InvalidOperationException("HTTP header value contains CR, LF, or NUL."); + } + + public static void ThrowIfForbidden(string name, string value) + { + if (ContainsForbiddenDelimiter(name)) + throw new InvalidOperationException("HTTP header name contains CR, LF, or NUL."); + if (ContainsForbiddenDelimiter(value)) + throw new InvalidOperationException("HTTP header value contains CR, LF, or NUL."); + } +} diff --git a/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs b/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs index a902bcba2..ef4bde99e 100644 --- a/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs +++ b/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs @@ -29,10 +29,38 @@ public abstract class RequestResponseBase internal Task? Http2BeforeHandlerTask; + /// + /// Off-loop (static-HPACK MITM) response dispatch only: completes as soon as HEADERS are queued or the + /// handler asked for the body, never when the user handler itself finishes. The frame loop gates DATA + /// on this; gating on would deadlock a handler awaiting + /// GetResponseBody on the inline (dynamic-HPACK / bridge) path, where that task is the raw handler. + /// + internal Task? Http2ResponseDispatchGate; + internal MemoryStream? Http2BodyData; internal bool Http2IgnoreBodyFrames; + /// True once this message's HEADERS have been queued toward the peer. SendBody must not send them again. + internal bool Http2HeadersQueued; + + /// + /// Requests that carry a body: completes once this request's origin decision is final and its HEADERS + /// are queued (or it will never be sent). The next request's dispatch chains on it so the origin + /// observes newly opened streams in increasing stream-id order (RFC 9113 §5.1.1) even when this + /// request's HEADERS are only sent after its buffered body arrived. Never awaited by the frame loop. + /// + internal TaskCompletionSource? Http2OriginAdmitted; + + /// + /// Admission of the previous request on the connection; a buffered-body request queues its HEADERS + /// only after this completes. Set when the HEADERS are deferred until the body arrived. + /// + internal Task? Http2AdmitAfter; + + /// True when the HEADERS were deferred until the buffered body was complete. + internal bool Http2HeadersDeferred; + /// /// Priority used only in HTTP/2 /// @@ -413,9 +441,14 @@ internal void ResetWireState() OriginalContentEncoding = null; HttpVersion = HttpHeader.VersionUnknown; Http2BeforeHandlerTask = null; + Http2ResponseDispatchGate = null; Http2BodyData?.Dispose(); Http2BodyData = null; Http2IgnoreBodyFrames = false; + Http2HeadersQueued = false; + Http2OriginAdmitted = null; + Http2AdmitAfter = null; + Http2HeadersDeferred = false; Priority = null; ReadHttp2BeforeHandlerTaskCompletionSource = null; ReadHttp2BodyTaskCompletionSource = null; diff --git a/src/Titanium.Web.Proxy/Http/Response.cs b/src/Titanium.Web.Proxy/Http/Response.cs index 5fd8a35cc..cca1d8b16 100644 --- a/src/Titanium.Web.Proxy/Http/Response.cs +++ b/src/Titanium.Web.Proxy/Http/Response.cs @@ -115,22 +115,62 @@ public bool KeepAlive { get { - Headers.TryGetUniqueHeader(KnownHeaders.Connection, out var connectionHeader); + // Connection is a comma-separated token list (RFC 9110 §7.6.1). "close" wins; + // duplicates / "close, te" must not keep the socket in the pool. + var close = false; + var keepAlive = false; + if (Headers.Headers.TryGetValue(KnownHeaders.Connection.String, out var unique)) + ClassifyConnectionTokens(unique.ValueData.Span, ref close, ref keepAlive); + else if (Headers.NonUniqueHeaders.TryGetValue(KnownHeaders.Connection.String, out var list)) + { + foreach (var header in list) + ClassifyConnectionTokens(header.ValueData.Span, ref close, ref keepAlive); + } - // HTTP/1.0 is non-persistent by default: the connection is only reusable when the - // response explicitly opts in with "Connection: keep-alive". Treating a plain HTTP/1.0 - // response as keep-alive would let us pool a connection the server is about to close. if (HttpVersion == HttpHeader.Version10) - return connectionHeader != null - && connectionHeader.ValueData.EqualsIgnoreCaseAscii(KnownHeaders.ConnectionKeepAlive.String8); + return keepAlive && !close; - // HTTP/1.1 (and HTTP/2) are persistent by default unless the response asks to close. - if (connectionHeader != null - && connectionHeader.ValueData.EqualsIgnoreCaseAscii(KnownHeaders.ConnectionClose.String8)) - return false; + return !close; + } + } + + private static void ClassifyConnectionTokens(ReadOnlySpan value, ref bool close, ref bool keepAlive) + { + while (!value.IsEmpty) + { + var comma = value.IndexOf((byte)','); + var token = comma >= 0 ? value[..comma] : value; + value = comma >= 0 ? value[(comma + 1)..] : default; + token = TrimAsciiWs(token); + if (AsciiEqualsIgnoreCase(token, "close"u8)) + close = true; + else if (AsciiEqualsIgnoreCase(token, "keep-alive"u8)) + keepAlive = true; + } + } - return true; + private static bool AsciiEqualsIgnoreCase(ReadOnlySpan a, ReadOnlySpan b) + { + if (a.Length != b.Length) return false; + for (var i = 0; i < a.Length; i++) + { + var x = a[i]; + var y = b[i]; + if (x is >= (byte)'A' and <= (byte)'Z') x = (byte)(x + 32); + if (y is >= (byte)'A' and <= (byte)'Z') y = (byte)(y + 32); + if (x != y) return false; } + + return true; + } + + private static ReadOnlySpan TrimAsciiWs(ReadOnlySpan span) + { + while (!span.IsEmpty && span[0] is (byte)' ' or (byte)'\t') + span = span[1..]; + while (!span.IsEmpty && span[^1] is (byte)' ' or (byte)'\t') + span = span[..^1]; + return span; } /// diff --git a/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs b/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs index 8b93e0274..c7e3ad7dc 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs @@ -1,6 +1,7 @@ using System; using System.Buffers; using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -94,24 +95,90 @@ public Http2ConnectionState(long connectionId, CancellationTokenSource cancellat /// public Http2OriginRelayPool? OriginRelayPool { get; set; } + /// + /// Client stream id → in-flight (cold + /// leg open). The gate-off frame loop must not await these; DATA awaits the task before + /// looking up the assignment so HEADERS cannot race DATA onto the wrong writer. + /// + public ConcurrentDictionary PendingOriginAssignments { get; } = new(); + /// Completed once the server's connection SETTINGS frame has been relayed to the client. public TaskCompletionSource ServerSettingsRelayed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); /// - /// Last SETTINGS_MAX_CONCURRENT_STREAMS value advertised toward the client (clamped / - /// injected on the origin→client SETTINGS rewrite). Not used for admission until the client - /// ACKs that SETTINGS (RFC 9113: sender must not assume the peer applied settings before ACK). - /// Default = unlimited (RFC default before a finite setting). + /// Last SETTINGS_MAX_CONCURRENT_STREAMS value written into an origin→client SETTINGS + /// frame. Diagnostic only: admission uses , + /// which advances one acknowledged frame at a time. Copying this field on every client ACK + /// applied a newer, lower cap to an ACK of an older frame and reset streams the browser was + /// still allowed to open (x.com timeline and comments). /// public int PendingMaxConcurrentStreamsTowardClient = int.MaxValue; /// - /// Concurrent-stream cap enforced against client-initiated stream admission. Promoted from - /// when a client SETTINGS ACK is received. + /// Concurrent-stream cap enforced against client-initiated stream admission. Stays at the RFC + /// default (unlimited) until the client ACKs the SETTINGS frame that advertised a finite cap. /// public int EnforcedMaxConcurrentStreamsTowardClient = int.MaxValue; + /// + /// Caps of non-ACK SETTINGS frames already written toward the client, in send order. + /// -1 means that frame did not change SETTINGS_MAX_CONCURRENT_STREAMS. + /// A client ACK consumes the oldest entry (RFC 9113: ACKs are not numbered and apply in order). + /// + private readonly Queue outboundSettingsCapsTowardClient = new(); + + /// + /// ACKs that arrived before ran for the frame + /// they acknowledge. Localhost can ACK in the window after the write returns and before the + /// relay task records the frame. + /// + private int unmatchedClientSettingsAcks; + + private readonly object settingsAckGate = new(); + + /// + /// Records one non-ACK SETTINGS frame that has been written toward the client. + /// is the cap that frame advertised, or + /// when the frame did not change the cap. + /// + internal void CommitSettingsFrameTowardClient(int? maxConcurrentStreams) + { + var encoded = maxConcurrentStreams ?? -1; + lock (settingsAckGate) + { + if (unmatchedClientSettingsAcks > 0) + { + unmatchedClientSettingsAcks--; + if (encoded >= 0) + Volatile.Write(ref EnforcedMaxConcurrentStreamsTowardClient, encoded); + return; + } + + outboundSettingsCapsTowardClient.Enqueue(encoded); + } + } + + /// + /// Applies one client SETTINGS ACK to the oldest SETTINGS frame written toward the client. + /// An ACK never adopts a cap that was staged for a frame the client has not been sent. + /// + internal void ApplyClientSettingsAck() + { + lock (settingsAckGate) + { + if (outboundSettingsCapsTowardClient.Count == 0) + { + unmatchedClientSettingsAcks++; + return; + } + + var encoded = outboundSettingsCapsTowardClient.Dequeue(); + if (encoded >= 0) + Volatile.Write(ref EnforcedMaxConcurrentStreamsTowardClient, encoded); + } + } + /// /// Background tasks for synthetic (proxy-generated) responses, tracked so they can be observed for /// failure and awaited before the owning relay direction completes. Completed tasks are unrooted diff --git a/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs b/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs index 2c18f947b..f37ed755d 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs @@ -77,24 +77,30 @@ public void RemoveStream(int streamId) /// Applies the peer's SETTINGS_INITIAL_WINDOW_SIZE (RFC 7540 §6.9.2): the delta from the previous /// value is applied to every currently tracked stream window (which may drive some negative - that /// is valid and callers must simply keep waiting), and the new value becomes the initial window for - /// streams registered after this point. + /// streams registered after this point. Returns true if any stream window would exceed + /// (FLOW_CONTROL_ERROR). /// - public void OnInitialWindowSizeChanged(int newValue) + public bool OnInitialWindowSizeChanged(int newValue) { lock (gate) { var delta = (long)newValue - initialStreamWindow; initialStreamWindow = newValue; + var overflow = false; if (delta != 0) { var streamIds = new List(streamWindows.Keys); foreach (var id in streamIds) { - streamWindows[id] += delta; + var updated = streamWindows[id] + delta; + streamWindows[id] = updated; + if (updated > MaxWindow) + overflow = true; } } WakeWaitersNoLock(); + return overflow; } } @@ -137,7 +143,8 @@ public bool OnWindowUpdate(int streamId, int increment) /// /// Non-blocking variant of . Returns false when credit is /// insufficient instead of waiting. Used to keep HEADERS + first DATA under one write lock - /// when the peer window already has room. + /// when the peer window already has room. Does not register unknown streams: a DATA path + /// that races must not leak a window entry or consume connection credit. /// public bool TryReserve(int streamId, int bytes) { @@ -146,10 +153,7 @@ public bool TryReserve(int streamId, int bytes) lock (gate) { if (!streamWindows.TryGetValue(streamId, out var streamWindow)) - { - streamWindow = initialStreamWindow; - streamWindows[streamId] = streamWindow; - } + return false; if (connectionWindow < bytes || streamWindow < bytes) return false; @@ -164,7 +168,8 @@ public bool TryReserve(int streamId, int bytes) /// Non-blocking reserve of as many of as currently fit in both the /// connection window and the stream window (Kestrel CheckStreamWindow shape). Returns the /// reserved count (0..); never waits. Used by the compressed-relay DATA - /// path so a short client window cannot park the shared origin frame reader. + /// path so a short client window cannot park the shared origin frame reader. Unknown streams + /// yield 0 (same race-safe contract as ). /// public int TryReservePartial(int streamId, int bytes) { @@ -173,10 +178,7 @@ public int TryReservePartial(int streamId, int bytes) lock (gate) { if (!streamWindows.TryGetValue(streamId, out var streamWindow)) - { - streamWindow = initialStreamWindow; - streamWindows[streamId] = streamWindow; - } + return 0; var streamAvail = streamWindow > 0 ? streamWindow : 0; var connAvail = connectionWindow > 0 ? connectionWindow : 0; diff --git a/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs b/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs index ace95cfbf..b29f481ff 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using Titanium.Web.Proxy.Diagnostics; namespace Titanium.Web.Proxy.Http2; @@ -25,6 +26,11 @@ internal sealed class Http2FrameWriter : IAsyncDisposable private const int CoalesceByteBudget = 288 * 1024; private const int CoalesceMaxFrames = 64; + // Watermark thresholds for Metrics (sparse samples on crossing only — not every enqueue). + private const int WatermarkSoft = 64; + private const int WatermarkWarn = 256; + private const int WatermarkCritical = 1024; + private readonly Channel> channel; private readonly System.IO.Stream output; private readonly SemaphoreSlim? writeLock; @@ -33,6 +39,9 @@ internal sealed class Http2FrameWriter : IAsyncDisposable // SingleReader drain: reuse coalesce scratch (avoids new ArraySegment[64] under multiplex). private readonly ArraySegment[] coalesceFrames = new ArraySegment[CoalesceMaxFrames]; private int disposed; + private int pendingFrames; + private int highWaterMark; + private int watermarkTierCrossed; // bit0=soft, bit1=warn, bit2=critical (per writer lifetime) public Http2FrameWriter(System.IO.Stream output, SemaphoreSlim? writeLock = null) { @@ -49,6 +58,12 @@ public Http2FrameWriter(System.IO.Stream output, SemaphoreSlim? writeLock = null drainTask = Task.Run(() => DrainAsync(cts.Token), cts.Token); } + /// Frames currently sitting in the channel (approximate; for tests/metrics). + public int PendingFrameCount => Volatile.Read(ref pendingFrames); + + /// Peak pending depth observed on this writer. + public int HighWaterMark => Volatile.Read(ref highWaterMark); + /// /// Enqueues a rented buffer for write. Ownership transfers; the buffer is returned to /// after the write (or on writer fault/dispose). @@ -61,8 +76,41 @@ public void EnqueueRented(byte[] rented, int length) return; } + var depth = Interlocked.Increment(ref pendingFrames); + // Relaxed high-water: races under count by at most one sample — fine for metrics. + var hw = highWaterMark; + if (depth > hw) + Interlocked.CompareExchange(ref highWaterMark, depth, hw); + + ObserveWatermarkCrossing(depth); + if (!channel.Writer.TryWrite(new ArraySegment(rented, 0, length))) + { + Interlocked.Decrement(ref pendingFrames); ArrayPool.Shared.Return(rented); + } + } + + private void ObserveWatermarkCrossing(int depth) + { + // Sparse: only the first crossing of each tier per writer lifetime. + if (depth >= WatermarkCritical) + RecordTier(0b100, "critical", depth); + else if (depth >= WatermarkWarn) + RecordTier(0b010, "warn", depth); + else if (depth >= WatermarkSoft) + RecordTier(0b001, "soft", depth); + } + + private void RecordTier(int bit, string name, int depth) + { + var prev = watermarkTierCrossed; + if ((prev & bit) != 0) + return; + if (Interlocked.CompareExchange(ref watermarkTierCrossed, prev | bit, prev) != prev) + return; + ProxyMetrics.Http2FrameWriterHighWaterCrossed(name); + ProxyMetrics.Http2FrameWriterDepthSample(depth, name); } public Task Completion => drainTask; @@ -87,6 +135,7 @@ public void EnqueueRented(byte[] rented, int length) while (reader.TryRead(out var first)) { + Interlocked.Decrement(ref pendingFrames); try { // Second grace after taking first frame — next writeLock holder may enqueue. @@ -116,6 +165,7 @@ public void EnqueueRented(byte[] rented, int length) && total < CoalesceByteBudget && reader.TryRead(out var next)) { + Interlocked.Decrement(ref pendingFrames); frames[count++] = next; total += next.Count; } @@ -169,6 +219,7 @@ public void EnqueueRented(byte[] rented, int length) { while (reader.TryRead(out var leftover)) { + Interlocked.Decrement(ref pendingFrames); if (leftover.Array != null) ArrayPool.Shared.Return(leftover.Array); } diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs index 963a166e1..ea23fb4a5 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs @@ -133,6 +133,7 @@ internal partial class Http2Helper "HTTP/2 header list too large: " + ex.Message, ex, sessionArgs)); await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, (Http2ErrorCode)0xb /* ENHANCE_YOUR_CALM */, input)); + removeAndFinalizeStream(hbStreamId); return false; } catch (Exception ex) @@ -146,8 +147,8 @@ internal partial class Http2Helper // connection down instead so both sides observe a clean failure and can retry on a new // connection. ReportException(logger, new ProxyHttpException("Failed to decode HTTP/2 headers", ex, sessionArgs)); - await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.CompressionError, input)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + connectionState.LastClientStreamId, Http2ErrorCode.CompressionError, input)); throw; } @@ -163,6 +164,7 @@ internal partial class Http2Helper ReportException(logger, new ProxyHttpException(malformed, null, sessionArgs)); await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, Http2ErrorCode.ProtocolError, input)); + removeAndFinalizeStream(hbStreamId); return false; } } @@ -178,6 +180,7 @@ internal partial class Http2Helper ReportException(logger, new ProxyHttpException(forbidden, null, sessionArgs)); await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, Http2ErrorCode.ProtocolError, input)); + removeAndFinalizeStream(hbStreamId); return false; } } @@ -383,6 +386,7 @@ internal partial class Http2Helper null, sessionArgs)); await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, Http2ErrorCode.ProtocolError, input)); + removeAndFinalizeStream(hbStreamId); return false; } @@ -396,6 +400,7 @@ internal partial class Http2Helper forbiddenTrailerHeader.Name + "'.", null, sessionArgs)); await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, Http2ErrorCode.ProtocolError, input)); + removeAndFinalizeStream(hbStreamId); return false; } @@ -407,15 +412,31 @@ internal partial class Http2Helper // a request answered synthetically never reached the server - nothing to forward, // but the block above still had to be decoded to keep this connection's HPACK // hpack.Decoder state in sync with the peer's encoder. - await hpack.RequestDispatchChain; - - if (!syntheticStreams.ContainsKey(hbStreamId)) + // + // Never await RequestDispatchChain (the connection-wide admission tail) here: for a + // buffered/deferred request that tail is this stream's own Http2OriginAdmitted, which + // only completes in SendBody after this method returns (frame-loop deadlock). Trailers + // also replace DATA END_STREAM, so the body-end path that runs SendBody sits right + // after we return — enqueueing trailers here would put them on the FIFO before HEADERS. + if (!syntheticStreams.ContainsKey(hbStreamId) && headerRr.Http2HeadersQueued) { - // Drain queued HEADERS/DATA so trailers cannot overtake them on the wire. - if (isClient) - await connectionState.ServerWriteChain; - await lockedOutputWrite(() => SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer, - hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output)); + // Streamed path: HEADERS (and DATA) are already on the FIFO. Await only this + // stream's own dispatch so trailers cannot overtake a still-pending HEADERS enqueue. + if (headerRr.Http2BeforeHandlerTask is { IsCompleted: false } ownDispatch) + await ownDispatch; + QueueSendTrailer(connectionState, towardServer: isClient, outputWriteLock, remoteSettings, + frameHeader, frameHeaderBuffer, hbStreamId, headerRr.TrailingHeaders, endStreamFlag, + output); + } + // else: deferred/buffered — TrailingHeaders stay on rr; SendBody emits them after + // HEADERS+DATA when the body-end path runs (endStreamFlag is set by the caller). + // External bridges (H2→H3): trailers replace DATA END_STREAM, so complete the body + // channel here or copyRequestBody hangs forever in ReadAllAsync. + if (connectionState.Streams.TryGetValue(hbStreamId, out var trailerBridgeState) + && trailerBridgeState.InboundRequestBodyChannel != null + && endStreamFlag) + { + trailerBridgeState.InboundRequestBodyChannel.Writer.TryComplete(); } return false; @@ -474,6 +495,14 @@ await lockedOutputWrite(() => SendTrailer(remoteSettings, frameHeader, frameHead TaskCompletionSource? tcs = endStreamFlag ? null : new TaskCompletionSource(); request.ReadHttp2BeforeHandlerTaskCompletionSource = tcs; + // Requests with a body may buffer it (GetRequestBody) and then send their HEADERS only at + // body end. The next request must not reach the origin before that (stream ids must + // increase on the wire), so its dispatch chains on this admission instead of the dispatch task. + var originAdmitted = tcs == null + ? null + : new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + request.Http2OriginAdmitted = originAdmitted; + var streamContext = new Http2StreamContext(hbStreamId, connectionState, isClient ? input : output, cancellationToken); @@ -595,7 +624,11 @@ async Task DispatchRequestAfterHeadersAsync(Task? prestartedHandler = null) // The origin must observe newly opened client streams in increasing stream-id order. // Handlers run concurrently, but admit each completed decision after the prior stream's // decision has queued (or suppressed) its HEADERS. - await previousDispatch; + // A request whose handler is waiting for the buffered body defers its HEADERS to body + // end; that wait must not run here (this task gates DATA routing on the frame loop, and + // the previous request's body may only arrive in frames behind this stream's DATA). + if (handlerCompleted) + await previousDispatch; if (handlerCompleted) { @@ -869,6 +902,8 @@ await RelayCompressedHeaderBlockAsync( } else { + request.Http2AdmitAfter = previousDispatch; + request.Http2HeadersDeferred = true; request.Http2IgnoreBodyFrames = true; } @@ -880,8 +915,27 @@ await RelayCompressedHeaderBlockAsync( Task dispatchTask = forceStaticHpackTable && httpInterceptionEnabled ? StartMitmStaticRequestDispatch() : Task.Run(() => DispatchRequestAfterHeadersAsync(), cancellationToken); - hpack.RequestDispatchChain = dispatchTask; request.Http2BeforeHandlerTask = dispatchTask; + if (originAdmitted == null) + { + hpack.RequestDispatchChain = dispatchTask; + } + else + { + // Admitted once dispatch queued/suppressed the HEADERS; when they are deferred to the + // buffered body's end, SendBody (or stream teardown) completes it instead. + hpack.RequestDispatchChain = originAdmitted.Task; + _ = dispatchTask.ContinueWith( + static (_, state) => + { + var r = (Request)state!; + if (!r.Http2HeadersDeferred) + r.Http2OriginAdmitted?.TrySetResult(true); + }, + request, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + pendingSynthetics.Track(dispatchTask); return false; } @@ -1229,6 +1283,7 @@ await RelayCompressedHeaderBlockAsync( { var dispatchTask = StartMitmStaticResponseDispatch(); response.Http2BeforeHandlerTask = dispatchTask; + response.Http2ResponseDispatchGate = dispatchTask; pendingSynthetics.Track(dispatchTask); return false; } @@ -1451,6 +1506,7 @@ await RelayCompressedHeaderBlockAsync( ReportException(logger, new ProxyHttpException(trailerForbidden, null, sessionArgs)); await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, Http2ErrorCode.ProtocolError, input)); + removeAndFinalizeStream(hbStreamId); return false; } } @@ -1460,10 +1516,16 @@ await RelayCompressedHeaderBlockAsync( headerRr.TrailingHeaders.AddHeader(header); } - // Drain queued response HEADERS/DATA so trailers cannot overtake them. - await connectionState.ClientWriteChain; - await lockedOutputWrite(() => SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer, - hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output)); + // Same contract as request trailers: only enqueue when HEADERS are already on the FIFO. + // GetResponseBody defers HEADERS to SendBody; enqueueing trailers here would put them first. + if (headerRr.Http2HeadersQueued) + { + if (headerRr.Http2BeforeHandlerTask is { IsCompleted: false } ownDispatch) + await ownDispatch; + QueueSendTrailer(connectionState, towardServer: false, outputWriteLock, remoteSettings, + frameHeader, frameHeaderBuffer, hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output); + } + return false; } } diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Relay.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Relay.cs index f8c84d5ee..b9d20a312 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Relay.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Relay.cs @@ -118,7 +118,7 @@ private static Task RelayCompressedHeaderBlockAsync( // NOSONAR S107 -- Relay co return Task.CompletedTask; } - private static async Task RelayCompressedWithOriginPoolAsync( // NOSONAR S107 -- Origin-pool assignment args stay explicit. + private static Task RelayCompressedWithOriginPoolAsync( // NOSONAR S107 -- Origin-pool assignment args stay explicit. Http2ConnectionState connectionState, bool isClient, Http2Settings remoteSettings, @@ -126,11 +126,44 @@ private static async Task RelayCompressedWithOriginPoolAsync( // NOSONAR S107 -- int hbStreamId, ReadOnlyMemory blockToRelay, bool endStreamFlag, byte[]? appendSuffix) { - var assignment = await connectionState.OriginRelayPool! // NOSONAR S8969 -- Caller already gated OriginRelayPool != null. - .AssignStreamAsync(hbStreamId, cancellationToken).ConfigureAwait(false); - EnqueueRelayedHeaderBlock(connectionState, isClient, remoteSettings, - assignment.OriginStreamId, blockToRelay, endStreamFlag, appendSuffix, - assignment.Leg.Writer, assignment.Leg.WriteLock, assignment.Leg.Stream); + // Warm path: existing assignment or capacity on an open leg — sync enqueue, no frame-loop await. + if (connectionState.OriginRelayPool!.TryAssignStreamWithoutOpen(hbStreamId, out var assignment)) + { + EnqueueRelayedHeaderBlock(connectionState, isClient, remoteSettings, + assignment.OriginStreamId, blockToRelay, endStreamFlag, appendSuffix, + assignment.Leg.Writer, assignment.Leg.WriteLock, assignment.Leg.Stream); + return Task.CompletedTask; + } + + // Cold path: may open a new TCP+H2 origin leg. Never run that await on the shared frame + // reader — publish a pending task so DATA waits for assignment instead. + return AssignOriginAndEnqueueAsync(connectionState, isClient, remoteSettings, cancellationToken, + hbStreamId, blockToRelay, endStreamFlag, appendSuffix); + } + + private static async Task AssignOriginAndEnqueueAsync( + Http2ConnectionState connectionState, + bool isClient, + Http2Settings remoteSettings, + CancellationToken cancellationToken, + int hbStreamId, ReadOnlyMemory blockToRelay, + bool endStreamFlag, byte[]? appendSuffix) + { + var assignTask = connectionState.OriginRelayPool! + .AssignStreamAsync(hbStreamId, cancellationToken); + var tracked = assignTask.AsTask(); + connectionState.PendingOriginAssignments[hbStreamId] = tracked; + try + { + var assignment = await assignTask.ConfigureAwait(false); + EnqueueRelayedHeaderBlock(connectionState, isClient, remoteSettings, + assignment.OriginStreamId, blockToRelay, endStreamFlag, appendSuffix, + assignment.Leg.Writer, assignment.Leg.WriteLock, assignment.Leg.Stream); + } + finally + { + connectionState.PendingOriginAssignments.TryRemove(hbStreamId, out _); + } } private static async Task RelayCompressedWithSchemeDecodeAsync( // NOSONAR S107 -- Scheme-decode fallback keeps the same explicit relay signature as the fast path. @@ -185,7 +218,7 @@ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAs ReportException(logger, new ProxyHttpException( "Failed to decode HTTP/2 headers on compressed-relay stream", ex, null)); await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.CompressionError, input)); + connectionState.LastClientStreamId, Http2ErrorCode.CompressionError, input)); throw; } diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs index fbb015335..efa743269 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs @@ -391,8 +391,10 @@ void RemoveAndFinalizeStream(int removeStreamId) if (leftover > 0) { - // Fire-and-forget under the loop; connection credit stays batched. - _ = GrantReceiveCreditLockedAsync(removeStreamId, 0, leftover).AsTask(); + // Fire-and-forget under the loop; connection credit stays batched. Observe faults + // so an unexpected GrantReceiveCredit failure is not an unobserved task exception. + var creditTask = GrantReceiveCreditLockedAsync(removeStreamId, 0, leftover).AsTask(); + pendingSynthetics.Track(creditTask); } connectionState.OriginRelayPool?.ReleaseStream(removeStreamId); @@ -401,6 +403,7 @@ void RemoveAndFinalizeStream(int removeStreamId) { removedState.InboundTunnelChannel?.Writer.TryComplete( new IOException("HTTP/2 stream removed due to protocol error.")); + CompleteAndDrainRequestBodyChannel(removedState); removedState.Cancellation.Cancel(); // Compressed-relay CTS is TryReset in PrepareForPool; disposing here forces a new CTS. if (!removedState.IsCompressedRelay) @@ -416,6 +419,16 @@ void RemoveAndFinalizeStream(int removeStreamId) Action removeAndFinalizeStream = RemoveAndFinalizeStream; Func, ValueTask> lockedOutputWriteFn = lockedOutputWrite; + static void CompleteAndDrainRequestBodyChannel(Http2StreamState state) + { + var channel = state.InboundRequestBodyChannel; + if (channel == null) + return; + channel.Writer.TryComplete(); + while (channel.Reader.TryRead(out var item)) + ArrayPool.Shared.Return(item.Buffer); + } + byte[] buffer = new byte[MaxAcceptableFrameSize]; // Typical HTTP/2 server stacks read a large Pipe buffer then peel frames with // Http2FrameReader.TryReadFrame. Mirror that without a ReadOnlySequence retrofit: @@ -485,6 +498,11 @@ async Task TrySendGracefulGoAwayAsync() var flags = (Http2FrameFlag)frameHeaderBuffer[4]; int streamId = ReadHttp2StreamId(frameHeaderBuffer); + // Cap carried by a non-ACK SETTINGS frame relayed toward the client on this iteration. + // null = the frame does not change SETTINGS_MAX_CONCURRENT_STREAMS. Applied only when + // that exact frame is ACKed (see Http2ConnectionState.ApplyClientSettingsAck). + int? clientBoundSettingsCap = null; + // Wire id on `input` (origin stream id when reading an origin leg). int peerStreamId = streamId; @@ -700,6 +718,36 @@ void MarkCompressedClosedIfFullySent() } } + // Lost-wakeup guard: the peer's WINDOW_UPDATE is handled by the *other* frame loop and + // may drain this queue between TryReservePartial/PendingCount and TryEnqueue, parking + // the bytes with no further WINDOW_UPDATE ever coming. Re-drain once they are queued. + void RedrainIfParked() + { + if (originReceiveLeg != null || dedicatedWriter == null || wireStreamId != dataStreamId + || sendDeferred.PendingCount(wireStreamId) == 0) + return; + + sendDeferred.TryDrain(sendFlow, dedicatedWriter, + sentId => + { + if (connectionState.Streams.TryGetValue(sentId, out var st) && st.IsClosed) + { + connectionState.OriginRelayPool?.ReleaseStream(sentId); + connectionState.RemoveStream(sentId); + ScheduleFinalize(st, onAfterResponse, logger, connectionState); + } + }, + queuedId => + { + if (!connectionState.Streams.TryGetValue(queuedId, out var st)) + return; + if (isClient) + st.RequestClosed = true; + else + st.ResponseClosed = true; + }); + } + void EnqueueWireFrame(ReadOnlySpan payload, bool endStream) { var wireLen = 9 + payload.Length; @@ -764,6 +812,7 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), if (length > 0 && payloadRented.Length > 0) ArrayPool.Shared.Return(payloadRented); + RedrainIfParked(); continue; } @@ -821,6 +870,7 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), connectionState.RemoveStream(dataStreamId); } + RedrainIfParked(); continue; } @@ -880,6 +930,7 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), } bool sendPacket = true; + bool hookEndStreamDeferred = false; bool endStream = false; SessionEventArgs? args = null; @@ -891,22 +942,31 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), args = existingStreamState.SessionArgs; } - // Request DATA must not be routed before the stream's BeforeRequest dispatch has finished: - // the dispatch task (thread-pool since the HEADERS decode was decoupled from handler - // execution) is what marks bridge/synthetic streams (syntheticStreams, Http2IgnoreBodyFrames). - // DATA racing past it falls through to the default relay and reserves send-window credit - // toward the origin leg - which for bridge connections is a NullOriginStream that never - // grants WINDOW_UPDATE, permanently leaking the 64 KiB connection window and deadlocking the - // whole frame loop in ReserveAsync (uploads and every response writer stall together). The - // The dispatch completes even when the user handler is still waiting on the request body + // DATA must not be routed before the stream's BeforeRequest/BeforeResponse dispatch has + // finished: the dispatch task is what marks bridge/synthetic streams and (on the MITM + // static-HPACK path) queues HEADERS onto the dedicated writer. DATA racing past it either + // reserves send-window credit toward a NullOriginStream (request side) or enqueues DATA + // ahead of still-pending response HEADERS (response side → PROTOCOL_ERROR on the client). + // The dispatch completes even when the user handler is still waiting on the body // (ReadHttp2BeforeHandlerTaskCompletionSource unblocks it), so awaiting here cannot deadlock. - // The END_STREAM/SendBody path below already relies on the same contract. - if (isClient && type == Http2FrameType.Data - && args?.HttpClient.Request.Http2BeforeHandlerTask is { IsCompleted: false } dataDispatch) + if (type == Http2FrameType.Data + && (isClient + ? args?.HttpClient.Request.Http2BeforeHandlerTask + : args?.HttpClient.Response.Http2ResponseDispatchGate) is { IsCompleted: false } dataDispatch) { await dataDispatch; } + // Gate-off multi-origin: cold AssignStreamAsync is published off the frame loop. DATA + // must wait for that assignment before TryGetAssignment / remapped send. + if (type == Http2FrameType.Data + && isClient + && connectionState.PendingOriginAssignments.TryGetValue(streamId, out var pendingOrigin) + && !pendingOrigin.IsCompleted) + { + await pendingOrigin; + } + if (type == Http2FrameType.Data && existingStreamState == null) { // DATA is flow-controlled at the connection level even when it arrives @@ -1037,11 +1097,15 @@ await lockedOwnLegWrite(() => SendRstStreamAsync( { var fragment = new byte[fragmentLength]; Buffer.BlockCopy(buffer, offset, fragment, 0, fragmentLength); - await RelayCompressedHeaderBlockAsync( + var relayTask = RelayCompressedHeaderBlockAsync( connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, streamId, fragment, endStreamFlag); + // Warm assign / verbatim enqueue completes inline. Cold origin open must + // not HOL this frame reader — DATA awaits PendingOriginAssignments. + if (!relayTask.IsCompletedSuccessfully) + connectionState.PendingSynthetics.Track(relayTask); if (endStreamFlag) endStream = true; } @@ -1229,11 +1293,13 @@ await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength), if (pCompressedRelay) { - await RelayCompressedHeaderBlockAsync( + var relayTask = RelayCompressedHeaderBlockAsync( connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, pStreamId, completeBlock, pEndStream); + if (!relayTask.IsCompletedSuccessfully) + connectionState.PendingSynthetics.Track(relayTask); if (pEndStream) endStream = true; } @@ -1313,9 +1379,10 @@ await RelayCompressedHeaderBlockAsync( "HTTP/2 bridge stream exceeded its bounded request-body buffer.", null, args)); RemoveAndFinalizeStream(streamId); - await lockedOwnLegWrite(() => SendRstStreamAsync( - new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.EnhanceYourCalm, input)); + // Queue RST on the client FIFO (isClient: InboundRequestBodyChannel is + // only used on the client→proxy leg for external bridges). + QueueRstStreamFrame(connectionState, input, streamId, + Http2ErrorCode.EnhanceYourCalm); } } @@ -1600,13 +1667,66 @@ await lockedOwnLegWrite(() => SendRstStreamAsync( // empty+END_STREAM must still be framed (trailers / bodiless end). if (outBytes.Length > 0 || endStreamFlag) { - // Queue on the same FIFO as QueueSendHeader. A direct SendData write can - // overtake MITM-re-encoded HEADERS still sitting on ClientFrameWriter / - // ServerFrameWriter (Inspector always subscribes OnResponseBodyWrite), - // which Chrome treats as DATA on an idle stream (PROTOCOL_ERROR). - await QueueSendData(connectionState, towardServer: isClient, outputWriteLock, - streamId, outBytes, endStreamFlag, remoteSettings.MaxFrameSize, outboundFlow, - output, cancellationToken); + // Queue on the same FIFO as QueueSendHeader. Never await ReserveAsync on + // the frame loop (HOL for WINDOW_UPDATE / other streams); defer when short. + var bodyDeferred = isClient + ? connectionState.ServerOutboundDeferred + : connectionState.ClientOutboundDeferred; + var queued = QueueSendData(connectionState, towardServer: isClient, + outputWriteLock, streamId, outBytes, endStreamFlag, remoteSettings.MaxFrameSize, + outboundFlow, bodyDeferred, output); + if (queued == QueueSendDataResult.EndStreamDeferred) + { + // Last byte is parked behind send-window credit: the WINDOW_UPDATE drain + // (OnDeferredEndStream) half-closes. Closing here would RemoveStream, + // which cancels the deferred queue and the flow window → stream never ends. + hookEndStreamDeferred = true; + } + else if (queued == QueueSendDataResult.CapExceeded) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 deferred DATA queue exceeded its per-stream cap.", null, args)); + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), + new byte[9], streamId, Http2ErrorCode.EnhanceYourCalm, input)); + RemoveAndFinalizeStream(streamId); + } + + if (queued != QueueSendDataResult.CapExceeded + && originReceiveLeg == null && bodyDeferred.PendingCount(streamId) > 0) + { + // Lost-wakeup guard: the peer's WINDOW_UPDATE is processed by the *other* + // frame loop and may have drained the queue between TryReservePartial + // (short) and TryEnqueue above, leaving these bytes parked with no further + // WINDOW_UPDATE ever coming. Re-drain now that they are queued. + var hookWriter = isClient + ? connectionState.ServerFrameWriter + : connectionState.ClientFrameWriter; + var hookTowardServer = isClient; + if (hookWriter != null) + { + bodyDeferred.TryDrain(outboundFlow, hookWriter, + sentId => + { + if (!connectionState.Streams.TryGetValue(sentId, out var st)) + return; + if (st.IsClosed) + { + connectionState.OriginRelayPool?.ReleaseStream(sentId); + connectionState.RemoveStream(sentId); + ScheduleFinalize(st, onAfterResponse, logger, connectionState); + } + }, + queuedId => + { + if (!connectionState.Streams.TryGetValue(queuedId, out var st)) + return; + if (hookTowardServer) + st.RequestClosed = true; + else + st.ResponseClosed = true; + }); + } + } } // we have emitted our own (possibly re-sized) DATA frame(s); suppress the default relay @@ -1848,14 +1968,11 @@ await lockedOwnLegWrite(async () => return; } - // Client ACK of SETTINGS we advertised toward it: promote Pending → Enforced so - // MaxConcurrentStreams admission matches what the browser has applied (RFC 9113). + // Client ACK of one SETTINGS frame we wrote toward it. ACKs are unnumbered and + // apply in order, so this must not copy the newest pending cap: that value may + // belong to a later SETTINGS frame still on its way to the browser. if (isClient && (flags & Http2FrameFlag.Ack) != 0) - { - Volatile.Write( - ref connectionState.EnforcedMaxConcurrentStreamsTowardClient, - Volatile.Read(ref connectionState.PendingMaxConcurrentStreamsTowardClient)); - } + connectionState.ApplyClientSettingsAck(); bool invalidSettings = false; Http2ErrorCode invalidSettingsError = Http2ErrorCode.ProtocolError; @@ -1933,7 +2050,11 @@ await lockedOwnLegWrite(async () => flow = connectionState.ClientSendFlow; else flow = connectionState.ServerSendFlow; - flow.OnInitialWindowSizeChanged((int)value); + if (flow.OnInitialWindowSizeChanged((int)value)) + { + invalidSettings = true; + invalidSettingsError = Http2ErrorCode.FlowControlError; + } if (!suppressConnectionFrameRelay && value < ClientInitialStreamWindowSize) { @@ -1973,8 +2094,10 @@ await lockedOwnLegWrite(async () => // against. var effective = Math.Min(advertised, resourceLimits.MaxConcurrentStreamsPerConnection); localSettings.MaxConcurrentStreams = effective; - // Advertise now; enforce only after the client's SETTINGS ACK. + // Record the value on this frame. Enforcement waits for the ACK of + // this frame, not the next ACK that happens to be in flight. Volatile.Write(ref connectionState.PendingMaxConcurrentStreamsTowardClient, effective); + clientBoundSettingsCap = effective; buffer[valueOffset] = (byte)((effective >> 24) & 0xff); buffer[valueOffset + 1] = (byte)((effective >> 16) & 0xff); @@ -2138,6 +2261,7 @@ await lockedOwnLegWrite(async () => var effective = resourceLimits.MaxConcurrentStreamsPerConnection; localSettings.MaxConcurrentStreams = effective; Volatile.Write(ref connectionState.PendingMaxConcurrentStreamsTowardClient, effective); + clientBoundSettingsCap = effective; buffer[length] = (byte)(((int)Http2SettingsId.MaxConcurrentStreams >> 8) & 0xff); buffer[length + 1] = (byte)((int)Http2SettingsId.MaxConcurrentStreams & 0xff); @@ -2217,11 +2341,16 @@ await lockedOwnLegWrite(async () => if (isClient) connectionState.MultipartObservers.TryRemove(streamId, out _); connectionState.OriginRelayPool?.ReleaseStream(streamId); + var resetBeforeOriginHeaders = false; if (connectionState.TryTakeStream(streamId, out var resetStream)) { + resetBeforeOriginHeaders = isClient + && resetStream.SessionArgs?.HttpClient.Request is + { Http2HeadersDeferred: true, Http2HeadersQueued: false }; // RFC 8441: if the reset stream is an extended CONNECT tunnel, unblock the relay // that is reading from the inbound channel so it can shut down promptly. resetStream.InboundTunnelChannel?.Writer.TryComplete(); + CompleteAndDrainRequestBodyChannel(resetStream); await resetStream.Cancellation.CancelAsync(); if (!resetStream.IsCompressedRelay) resetStream.Cancellation.Dispose(); @@ -2340,6 +2469,13 @@ await lockedOwnLegWrite(async () => new ProxyHttpException( $"HTTP/2 peer stream reset code {errorCode}", null, args)); } + + // Client RST for a buffered request whose HEADERS were deferred to body end and never + // written: the origin has not seen the stream, so forwarding the RST would be an RST on + // an idle stream (connection PROTOCOL_ERROR). (Captured before finalize, which may + // reset the request object.) + if (resetBeforeOriginHeaders) + sendPacket = false; } if (endStream && rr == null) @@ -2435,14 +2571,36 @@ await lockedOwnLegWrite(async () => connectionState.Streams.TryGetValue(streamId, out var bodyStreamState); if (bodyStreamState?.IsExternalBridge != true) { - // Drain queued HEADERS/DATA so this SendBody cannot overtake them on the wire. - if (isClient) - await connectionState.ServerWriteChain; + // HEADERS + DATA join the direction's ordered writer FIFO behind whatever is already + // queued (see SendBody). Draining the legacy write chain here was a no-op with the + // dedicated frame writers, which is how late HEADERS overtook queued blocks. + // + // Origin-bound requests are additionally admitted in stream-id order: this request's + // HEADERS are only encoded now, so a later stream may already have been sent. + // If an earlier request is still waiting for its body, send from a tracked + // background task; the frame loop must keep reading (that body is behind us). + var admitted = rr.Http2OriginAdmitted; + var admitAfter = rr.Http2AdmitAfter; + if (isClient && admitted != null && admitAfter is { IsCompleted: false } + && rr is Request deferredRequest) + { + pendingSynthetics.Track(SendBufferedRequestAfterAdmissionAsync(connectionState, + remoteSettings, deferredRequest, streamId, buffer.Length, outboundFlow, output, input, + outputWriteLock, admitAfter, admitted, logger, args, cancellationToken)); + } else - await connectionState.ClientWriteChain; - await lockedOutputWrite(() => - SendBody(remoteSettings, rr, frameHeader, frameHeaderBuffer, buffer, outboundFlow, - output, cancellationToken)); + { + try + { + await SendBody(connectionState, towardServer: isClient, remoteSettings, rr, + frameHeader, frameHeaderBuffer, buffer.Length, outboundFlow, output, + cancellationToken, outputWriteLock); + } + finally + { + admitted?.TrySetResult(true); + } + } } } @@ -2450,7 +2608,7 @@ await lockedOutputWrite(() => // half-close / finalize until the last byte is actually queued (see sendPacket / // OnDeferredEndStream). Non-DATA END_STREAM (HEADERS) and DATA that is not forwarded // still close here. - if (endStream && (type != Http2FrameType.Data || !sendPacket)) + if (endStream && !hookEndStreamDeferred && (type != Http2FrameType.Data || !sendPacket)) { if (isClient) connectionState.MultipartObservers.TryRemove(streamId, out _); @@ -2703,6 +2861,11 @@ async ValueTask writeFrame() // response on the other relay can safely send HEADERS afterwards. if (!isClient && type == Http2FrameType.Settings && (flags & Http2FrameFlag.Ack) == 0) { + // The SETTINGS bytes are already written (connection-level frames are awaited + // under the output lock above). Pair this frame with its future ACK before any + // further await, so a localhost ACK cannot land against an empty queue and + // then be matched to a later, lower cap. + connectionState.CommitSettingsFrameTowardClient(clientBoundSettingsCap); connectionState.ServerSettingsRelayed.TrySetResult(true); // 1 MiB connection window toward the client — must follow SETTINGS on the diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Finalize.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Finalize.cs index f18736237..70e21b6fa 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Finalize.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Finalize.cs @@ -49,6 +49,10 @@ internal static async Task FinalizeStreamAsync(Http2StreamState state, return; } + // A buffered request reset or aborted before its body completed never reaches SendBody; release + // the stream-id ordering chain so later requests are not held back by a dead stream. + state.SessionArgs.HttpClient.Request.Http2OriginAdmitted?.TrySetResult(true); + try { var requestDispatch = state.SessionArgs.HttpClient.Request.Http2BeforeHandlerTask; diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs index 423ced70f..3ea499481 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs @@ -71,15 +71,17 @@ private static ByteString AsciiToLowerByteString(ByteString name) /// /// Hop-by-hop / connection-specific names RFC 7540 §8.1.2.2 forbids on HTTP/2 (plus Host, which - /// becomes :authority). Compared on so EncodeHeaderBlock does not - /// force header.Name GetString under writeLock. + /// becomes :authority). TE is omitted unless its value is exactly trailers + /// (the one TE value HTTP/2 allows). Compared on so EncodeHeaderBlock + /// does not force header.Name GetString under writeLock. /// - private static bool ShouldOmitHttp2Header(ByteString name) + private static bool ShouldOmitHttp2Header(ByteString name, ByteString value) { var span = name.Span; return span.Length switch { - 2 => EqualsAsciiIgnoreCase(span, "te"u8), + 2 => EqualsAsciiIgnoreCase(span, "te"u8) + && !EqualsAsciiIgnoreCase(value.Span, "trailers"u8), 4 => EqualsAsciiIgnoreCase(span, "host"u8), 7 => EqualsAsciiIgnoreCase(span, "upgrade"u8), 10 => EqualsAsciiIgnoreCase(span, "connection"u8) @@ -274,7 +276,7 @@ private static bool EqualsAsciiIgnoreCase(ReadOnlySpan a, ReadOnlySpan + /// Direct socket write of a HEADERS block. Prefer so encode order + /// matches wire order on the dedicated frame writer. Kept for unit-test coverage of the legacy + /// path; production relay code must not call this. + /// internal static ValueTask SendHeader(Http2Settings settings, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. { // Same HPACK lock as QueueSendHeader: Encoder + encode scratch are connection-direction scoped. @@ -61,6 +66,7 @@ private static void QueueSendHeader(Http2ConnectionState connectionState, bool t pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, rr.Priority.HasValue, block, settings.MaxFrameSize); connectionState.EnqueueWriteRented(towardServer, writeLock, output, framed.Array!, framed.Count); + rr.Http2HeadersQueued = true; } } @@ -106,13 +112,10 @@ private static void QueueDataFrame(Http2ConnectionState connectionState, Stream clientStream, streamId, payload, endStream); /// - /// Same framing and flow-control reservation as , but the frames are - /// queued on the dedicated writer FIFO used by . The per-chunk - /// OnRequestBodyWrite/OnResponseBodyWrite path previously called - /// (direct locked write). That raced the MITM HEADERS enqueue: DATA could hit the peer socket - /// first, which Chrome treats as DATA on an idle stream (ERR_HTTP2_PROTOCOL_ERROR). + /// Off-loop variant used by : may await flow-control credit because it + /// does not run on the shared frame reader. /// - private static async ValueTask QueueSendData(Http2ConnectionState connectionState, bool towardServer, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. + private static async ValueTask QueueSendDataAsync(Http2ConnectionState connectionState, bool towardServer, SemaphoreSlim writeLock, int streamId, ReadOnlyMemory data, bool endStream, int maxFrameSize, Http2FlowController flow, Stream output, CancellationToken cancellationToken) { @@ -137,11 +140,110 @@ private static void QueueDataFrame(Http2ConnectionState connectionState, Stream } } + /// + /// Same framing as , but frames are queued on the dedicated writer FIFO + /// used by . Never awaits : + /// parking the shared frame reader on credit would HOL-block WINDOW_UPDATE (and every other + /// stream). When credit is short, payload bytes go onto and drain + /// when the peer grants window — same shape as the compressed-relay DATA path. + /// + /// : END_STREAM is parked on + /// ; the caller must not half-close / RemoveStream (that cancels + /// the deferred queue and drops the flow window) — the drain's end-stream callback closes. + /// : the per-stream cap rejected bytes; the caller + /// must reset the stream (silently dropping them would hang the peer). + /// + /// + private static QueueSendDataResult QueueSendData(Http2ConnectionState connectionState, bool towardServer, + SemaphoreSlim writeLock, int streamId, ReadOnlyMemory data, bool endStream, int maxFrameSize, + Http2FlowController flow, Http2DeferredOutboundData deferred, Stream output) + { + if (maxFrameSize <= 0) maxFrameSize = 16384; + var result = QueueSendDataResult.Sent; + + if (data.Length == 0) + { + if (deferred.PendingCount(streamId) > 0) + { + // End-stream must not overtake deferred bytes. + var empty = ArrayPool.Shared.Rent(1); + if (!deferred.TryEnqueue(streamId, empty, 0, 0, endStream)) + { + ArrayPool.Shared.Return(empty); + return QueueSendDataResult.CapExceeded; + } + + return endStream ? QueueSendDataResult.EndStreamDeferred : QueueSendDataResult.Sent; + } + + QueueDataFrame(connectionState, towardServer, writeLock, output, streamId, + ReadOnlyMemory.Empty, endStream); + return QueueSendDataResult.Sent; + } + + var pos = 0; + while (pos < data.Length) + { + var remaining = data.Length - pos; + var want = Math.Min(maxFrameSize, remaining); + var isLastSlice = pos + want >= data.Length; + + if (deferred.PendingCount(streamId) > 0) + { + var rented = ArrayPool.Shared.Rent(want); + data.Span.Slice(pos, want).CopyTo(rented); + if (!deferred.TryEnqueue(streamId, rented, 0, want, isLastSlice && endStream)) + { + ArrayPool.Shared.Return(rented); + return QueueSendDataResult.CapExceeded; + } + + if (isLastSlice && endStream) + result = QueueSendDataResult.EndStreamDeferred; + pos += want; + continue; + } + + var reserved = flow.TryReservePartial(streamId, want); + if (reserved > 0) + { + QueueDataFrame(connectionState, towardServer, writeLock, output, streamId, + data.Slice(pos, reserved), isLastSlice && reserved == want && endStream); + pos += reserved; + } + + if (reserved < want) + { + var rem = want - reserved; + var rented = ArrayPool.Shared.Rent(rem); + data.Span.Slice(pos, rem).CopyTo(rented); + if (!deferred.TryEnqueue(streamId, rented, 0, rem, isLastSlice && endStream)) + { + ArrayPool.Shared.Return(rented); + return QueueSendDataResult.CapExceeded; + } + + if (isLastSlice && endStream) + result = QueueSendDataResult.EndStreamDeferred; + pos += rem; + } + } + + return result; + } + + private enum QueueSendDataResult + { + Sent, + EndStreamDeferred, + CapExceeded, + } + /// /// Queues a client-bound RST_STREAM through the same FIFO as the stream's queued HEADERS/DATA so /// it cannot overtake them on the wire (a direct locked write could). /// - private static void QueueRstStreamFrame(Http2ConnectionState connectionState, Stream clientStream, + internal static void QueueRstStreamFrame(Http2ConnectionState connectionState, Stream clientStream, int streamId, Http2ErrorCode errorCode) { const int frameSize = 9 + 4; @@ -164,11 +266,15 @@ private static void QueueRstStreamFrame(Http2ConnectionState connectionState, St /// HEADERS frame carrying no pseudo-headers, using the same persistent per-direction HPACK /// encoder as so the destination's dynamic table stays in sync /// regardless of whether trailers are actually present on a given message. + /// The block is encoded and enqueued on the direction's ordered FIFO under one + /// settings.Sync hold (same contract as ): a direct write + /// would let this block overtake earlier-encoded HEADERS still queued on the dedicated writer, + /// and the peer's HPACK decoder would desynchronize (COMPRESSION_ERROR). /// - internal static ValueTask SendTrailer(Http2Settings settings, Http2FrameHeader frameHeader, + internal static void QueueSendTrailer(Http2ConnectionState connectionState, bool towardServer, + SemaphoreSlim writeLock, Http2Settings settings, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream, Stream output) { - ReadOnlyMemory block; lock (settings.Sync) { var encoder = settings.Encoder; @@ -200,12 +306,11 @@ internal static ValueTask SendTrailer(Http2Settings settings, Http2FrameHeader f } writer.Flush(); - // Encode scratch is reused; copy before releasing the HPACK lock. - block = GetMemoryStreamMemory(ms).ToArray(); + // RentFramedHeaderBlock copies out of the shared encode scratch before the lock is released. + var framed = RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, streamId, Http2FrameType.Headers, + endStream, false, GetMemoryStreamMemory(ms), settings.MaxFrameSize); + connectionState.EnqueueWriteRented(towardServer, writeLock, output, framed.Array!, framed.Count); } - - return WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, streamId, Http2FrameType.Headers, - endStream, false, block, settings.MaxFrameSize, output); } private static ReadOnlyMemory GetMemoryStreamMemory(MemoryStream ms) @@ -364,36 +469,96 @@ private static async ValueTask WriteHeaderBlockMultiAsync(Http2FrameHeader frame } while (pos < data.Length); } - internal static async ValueTask SendBody(Http2Settings settings, RequestResponseBase rr, Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. - byte[] frameHeaderBuffer, byte[] buffer, Http2FlowController flow, Stream output, - CancellationToken cancellationToken) + /// + /// Sends a message whose body the handler buffered (GetRequestBody / GetResponseBody): HEADERS + /// (unless dispatch already queued them) followed by the DATA frames. + /// Both go through the direction's ordered writer FIFO ( / + /// ), never a direct socket write. These HEADERS are encoded late, + /// after the frame loop has already encoded and queued HEADERS for other streams. A direct + /// write here landed on the wire ahead of those queued blocks although it was encoded after + /// them, so the peer's HPACK decoder desynchronized (COMPRESSION_ERROR / illegal index), and a + /// DATA frame could precede its own HEADERS. Awaiting the legacy write chain does not help: the + /// dedicated frame writers never feed it. The FIFO also means no write lock is held while + /// waiting for flow-control credit. + /// + internal static async ValueTask SendBody(Http2ConnectionState connectionState, bool towardServer, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. + Http2Settings settings, RequestResponseBase rr, Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, int maxDataFrameSize, Http2FlowController flow, Stream output, + CancellationToken cancellationToken, SemaphoreSlim writeLock) { var body = rr.CompressBodyAndUpdateContentLength(); - await SendHeader(settings, frameHeader, frameHeaderBuffer, rr, !(rr.HasBody && rr.IsBodyRead), output, false); + var hasBody = rr.HasBody && rr.IsBodyRead; + if (hasBody && body == null) + throw new InvalidOperationException("An HTTP/2 body was marked as read but is unavailable."); + + // Dispatch may already have queued HEADERS. Encoding them again makes the peer decode the + // priority prefix as HPACK (COMPRESSION_ERROR) or reject the second block (PROTOCOL_ERROR). + var streamId = frameHeader.StreamId; + var hasTrailers = rr.HasTrailingHeaders; + // END_STREAM belongs on the trailer HEADERS when trailers are present (RFC 9113 §8.1). + if (!rr.Http2HeadersQueued) + QueueSendHeader(connectionState, towardServer, writeLock, settings, frameHeader, + frameHeaderBuffer, rr, endStream: !hasBody && !hasTrailers, output, pushPromise: false); - if (rr.HasBody && rr.IsBodyRead) + if (hasBody) { - if (body == null) - throw new InvalidOperationException("An HTTP/2 body was marked as read but is unavailable."); + var maxFrameSize = settings.MaxFrameSize > 0 + ? Math.Min(maxDataFrameSize, settings.MaxFrameSize) + : maxDataFrameSize; + // SendBody runs off the frame loop (admission / body-complete); awaiting credit is safe. + await QueueSendDataAsync(connectionState, towardServer, writeLock, streamId, body!, + endStream: !hasTrailers, maxFrameSize, flow, output, cancellationToken) + .ConfigureAwait(false); + } - int streamId = frameHeader.StreamId; - int pos = 0; - while (pos < body.Length) - { - int bodyFrameLength = Math.Min(buffer.Length, body.Length - pos); - Buffer.BlockCopy(body, pos, buffer, 0, bodyFrameLength); - pos += bodyFrameLength; + if (hasTrailers) + QueueSendTrailer(connectionState, towardServer, writeLock, settings, frameHeader, + frameHeaderBuffer, streamId, rr.TrailingHeaders, endStream: true, output); + } - await flow.ReserveAsync(streamId, bodyFrameLength, cancellationToken); + /// + /// Sends a buffered request once every earlier request on the connection has been admitted, so the + /// origin sees HEADERS in increasing stream-id order (RFC 9113 §5.1.1: a lower id after a higher + /// one is a connection PROTOCOL_ERROR). Runs off the frame loop: the frame that completes the + /// earlier request's body may sit behind this stream's END_STREAM, so the loop must never wait here. + /// Always completes so later requests are never held back. + /// + internal static async Task SendBufferedRequestAfterAdmissionAsync(Http2ConnectionState connectionState, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. + Http2Settings settings, Request request, int streamId, int maxDataFrameSize, Http2FlowController flow, + Stream serverOutput, Stream clientStream, SemaphoreSlim serverWriteLock, Task admitAfter, + TaskCompletionSource admitted, ILogger logger, SessionEventArgs? args, + CancellationToken cancellationToken) + { + try + { + try + { + await admitAfter.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + // The earlier request failing is its own problem; this one is still ordered after it. + } - frameHeader.Length = bodyFrameLength; - frameHeader.Type = Http2FrameType.Data; - frameHeader.Flags = pos < body.Length ? (Http2FrameFlag)0 : Http2FrameFlag.EndStream; + if (!connectionState.Streams.ContainsKey(streamId)) + return; // reset or closed while waiting: nothing to send - frameHeader.CopyToBuffer(frameHeaderBuffer); - await WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), - buffer.AsMemory(0, bodyFrameLength), cancellationToken); - } + await SendBody(connectionState, towardServer: true, settings, request, + new Http2FrameHeader { StreamId = streamId }, new byte[9], maxDataFrameSize, flow, serverOutput, + cancellationToken, serverWriteLock).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // connection is going away + } + catch (Exception ex) + { + ReportException(logger, new ProxyHttpException("HTTP/2 buffered request send failed", ex, args)); + QueueRstStreamFrame(connectionState, clientStream, streamId, Http2ErrorCode.InternalError); + } + finally + { + admitted.TrySetResult(true); } } @@ -412,6 +577,11 @@ await WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), /// blocked trying to take the same lock for control-frame replies. Matches the order used by /// the main DATA relay. /// + /// + /// Direct socket write of DATA frames. Prefer so DATA cannot overtake + /// HEADERS still queued on the dedicated frame writer. Kept for unit-test coverage of the legacy + /// path; production relay code must not call this. + /// internal static async ValueTask SendData(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, int streamId, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. ReadOnlyMemory data, bool endStream, int maxFrameSize, Http2FlowController flow, Stream output, CancellationToken cancellationToken, SemaphoreSlim? writeLock = null) @@ -759,15 +929,11 @@ internal static async Task EmitInterimResponseAsync(SessionEventArgs args, int s endStream: false, clientStream, pushPromise: false); // Flush so Navigation Timing responseStart can move before the final response arrives. - await connectionState.ClientWriteLock.WaitAsync(cancellationToken); - try - { - await clientStream.FlushAsync(cancellationToken); - } - finally - { - connectionState.ClientWriteLock.Release(); - } + // Do not take ClientWriteLock around Flush: the 1xx HEADERS are already on ClientFrameWriter's + // FIFO and the drain shares that lock — holding it here can Flush before the queued frame + // is written. Best-effort flush after a yield lets the drain run first. + await Task.Yield(); + await clientStream.FlushAsync(cancellationToken); } /// @@ -1464,6 +1630,22 @@ public void AddHeader(ByteString name, ByteString value, bool sensitive) => } } + // RFC 9113 §8.2.1: field names and values must not contain CR, LF, or NUL. + if (!HasMalformedHeader && ContainsCtlDelimiter(name.Span)) + { + HasMalformedHeader = true; + MalformedReason = "header field name contains CR, LF, or NUL"; + } + + if (!HasMalformedHeader && ContainsCtlDelimiter(value.Span)) + { + HasMalformedHeader = true; + MalformedReason = "header field value contains CR, LF, or NUL"; + } + + if (HasMalformedHeader) + return; + addHeaderFunc?.Invoke(name, value); if (prebuilt != null) decodeTarget?.AddHeader(prebuilt); @@ -1471,6 +1653,17 @@ public void AddHeader(ByteString name, ByteString value, bool sensitive) => decodeTarget?.AddHeader(new HttpHeader(name, value)); } + private static bool ContainsCtlDelimiter(ReadOnlySpan span) + { + foreach (var b in span) + { + if (b is 0 or (byte)'\r' or (byte)'\n') + return true; + } + + return false; + } + private void MarkMalformed(string reason) { if (!HasMalformedHeader) diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs index fa8bb46e0..f7a4f0842 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs @@ -574,7 +574,7 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream: // Known-CL bodies that exceeded the inline threshold still buffer then return so H1 // deliver can coalesce headers+body. Streaming via StreamBodyWriter pays an extra // pipe+async hop per request for these. - if (response.ContentLength is >= 0 and <= 8 * 1024) + if (response.ContentLength is >= 0 and <= PendingStream.InlineBodyThresholdBytes) { var expected = (int)response.ContentLength; byte[] body; @@ -1035,7 +1035,17 @@ private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, Semap continue; } - sendFlow.OnWindowUpdate(streamId, increment); + if (sendFlow.OnWindowUpdate(streamId, increment)) + { + // RFC 9113 §6.9.1: window exceeding 2^31-1 is FLOW_CONTROL_ERROR. + if (streamId == 0) + { + Fail(new IOException("HTTP/2 protocol error: connection flow-control window overflow.")); + return; + } + + Http2Helper.EnqueueRstStream(Writer, streamId, Http2ErrorCode.FlowControlError); + } } continue; @@ -1333,7 +1343,12 @@ private void ApplySettings(ReadOnlySpan payload) return; } - sendFlow.OnInitialWindowSizeChanged(value); + if (sendFlow.OnInitialWindowSizeChanged(value)) + { + Fail(new IOException( + "HTTP/2 protocol error: SETTINGS_INITIAL_WINDOW_SIZE drove a stream window above 2^31-1.")); + return; + } } else if (identifier == (int)Http2SettingsId.MaxConcurrentStreams) originSettings.MaxConcurrentStreams = value; @@ -1761,9 +1776,10 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo private sealed class PendingStream : IDisposable { /// - /// Known Content-Length bodies ≤ 8 KiB are filled here on the ReadLoop (no ). + /// Known Content-Length bodies ≤ 64 KiB are filled here on the ReadLoop (no ). + /// Matches H3→H1 / H2→H1 eager-buffer so 64 KiB reverse bodies skip the pipe+async hop. /// - internal const int InlineBodyThresholdBytes = 8 * 1024; + internal const int InlineBodyThresholdBytes = 64 * 1024; private BoundedBodyPipe? bodyPipe; private readonly long maxBodyBytes; diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs index d719fad71..aa02879e2 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs @@ -56,6 +56,50 @@ public IReadOnlyList SnapshotLegs() lock (gate) return legs.ToArray(); } + /// + /// Assigns to an origin leg without opening a new connection. + /// Returns when every open leg is at soft capacity and a cold open is + /// required (caller must use off the frame loop). + /// + public bool TryAssignStreamWithoutOpen(int clientStreamId, out StreamAssignment assignment) + { + if (clientToOrigin.TryGetValue(clientStreamId, out assignment!)) + return true; + + OriginLeg? chosen; + lock (gate) + { + chosen = PickLegUnderLock(); + if (chosen == null || NeedsNewLeg(chosen)) + { + assignment = default!; + return false; + } + } + + int originStreamId; + lock (chosen.IdLock) + { + originStreamId = chosen.NextStreamId; + chosen.NextStreamId += 2; + chosen.ActiveStreams++; + } + + chosen.SendFlow.RegisterStream(originStreamId); + + assignment = new StreamAssignment(chosen, originStreamId); + if (!clientToOrigin.TryAdd(clientStreamId, assignment)) + { + chosen.SendFlow.RemoveStream(originStreamId); + lock (chosen.IdLock) chosen.ActiveStreams--; + assignment = clientToOrigin[clientStreamId]; + return true; + } + + chosen.OriginToClient[originStreamId] = clientStreamId; + return true; + } + /// /// Assigns to an origin leg, opening a new leg when every /// existing leg is at its soft capacity and the pool has room. @@ -63,7 +107,7 @@ public IReadOnlyList SnapshotLegs() public async ValueTask AssignStreamAsync(int clientStreamId, CancellationToken cancellationToken) { - if (clientToOrigin.TryGetValue(clientStreamId, out var existing)) + if (TryAssignStreamWithoutOpen(clientStreamId, out var existing)) return existing; OriginLeg? chosen; @@ -84,6 +128,10 @@ public async ValueTask AssignStreamAsync(int clientStreamId, lock (gate) chosen = PickLeastLoadedUnderLock(); } + // After a cold open (or while waiting), another racer may have assigned this stream. + if (clientToOrigin.TryGetValue(clientStreamId, out var raced)) + return raced; + int originStreamId; lock (chosen.IdLock) { diff --git a/src/Titanium.Web.Proxy/Http2/Http2PendingWork.cs b/src/Titanium.Web.Proxy/Http2/Http2PendingWork.cs index 3266082c9..4391054a6 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2PendingWork.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2PendingWork.cs @@ -1,5 +1,8 @@ +using System; using System.Collections.Concurrent; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Logging; namespace Titanium.Web.Proxy.Http2; @@ -11,21 +14,29 @@ namespace Titanium.Web.Proxy.Http2; internal sealed class Http2PendingWork { private readonly ConcurrentDictionary pending = new(); + private readonly ILogger? logger; + + public Http2PendingWork(ILogger? logger = null) => this.logger = logger; public bool IsEmpty => pending.IsEmpty; public void Track(Task task) { if (task.IsCompleted) + { + ObserveFault(task); return; + } if (!pending.TryAdd(task, 0)) return; _ = task.ContinueWith(static (t, state) => { - ((ConcurrentDictionary)state!).TryRemove(t, out _); - }, pending, TaskContinuationOptions.ExecuteSynchronously); + var self = (Http2PendingWork)state!; + self.pending.TryRemove(t, out _); + self.ObserveFault(t); + }, this, TaskContinuationOptions.ExecuteSynchronously); } public Task WhenAllAsync() @@ -38,6 +49,16 @@ public Task WhenAllAsync() var i = 0; foreach (var t in snapshot) array[i++] = t; - return Task.WhenAll(array); + // OnlyWaitOnAll: faults were already observed/logged in Track's continuation; do not let a + // single synthetic failure tear down the whole relay via Task.WhenAll's aggregate throw. + return Task.WhenAll(array).ContinueWith(static _ => { }, TaskContinuationOptions.ExecuteSynchronously); + } + + private void ObserveFault(Task task) + { + if (!task.IsFaulted || task.Exception == null) + return; + ProxyDiagnostics.ReportCaught(logger ?? ProxyDiagnostics.Logger, + "HTTP/2 background work faulted", task.Exception.GetBaseException()); } } diff --git a/src/Titanium.Web.Proxy/Http2/Http2StreamState.cs b/src/Titanium.Web.Proxy/Http2/Http2StreamState.cs index cff942eb3..3d7f7e6cd 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2StreamState.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2StreamState.cs @@ -191,25 +191,40 @@ internal void PrepareForPool() SessionArgs = null; // Prefer TryReset over dispose+new: compressed-relay streams churn one CTS per request // otherwise (Cancel on finalize path, then Return). - if (!Cancellation.TryReset()) - { - try { Cancellation.Dispose(); } - catch { /* ignore */ } - Cancellation = new CancellationTokenSource(); - } + ReplaceCancellationIfNotResettable(); ResetMutableFields(preserveCancellation: true); } - private void ResetMutableFields(bool preserveCancellation = false) + /// + /// RST / protocol-error teardown disposes the non-relay CTS before finalize returns the state to + /// the pool; throws on a disposed instance. + /// + private void ReplaceCancellationIfNotResettable() { - if (!preserveCancellation && !Cancellation.TryReset()) + bool reset; + try + { + reset = Cancellation.TryReset(); + } + catch (ObjectDisposedException) { - try { Cancellation.Dispose(); } - catch { /* ignore */ } - Cancellation = new CancellationTokenSource(); + reset = false; } + if (reset) + return; + + try { Cancellation.Dispose(); } + catch { /* ignore */ } + Cancellation = new CancellationTokenSource(); + } + + private void ResetMutableFields(bool preserveCancellation = false) + { + if (!preserveCancellation) + ReplaceCancellationIfNotResettable(); + RequestClosed = false; ResponseClosed = false; SyntheticTask = null; diff --git a/src/Titanium.Web.Proxy/Http3/Http3Connection.cs b/src/Titanium.Web.Proxy/Http3/Http3Connection.cs index ba42c2b82..d50c021b5 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3Connection.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3Connection.cs @@ -532,9 +532,25 @@ private async Task FinalizeAllStreamsAsync() { _logger.LogError(ex, "Error in AfterResponse during HTTP/3 connection teardown"); } + + try + { + await state.Cancellation.CancelAsync(); + } + catch (ObjectDisposedException) + { + // Stream path already returned/disposed the CTS. + } + + try + { + state.Cancellation.Dispose(); + } + catch (ObjectDisposedException) + { + // Already returned to the session CTS pool by the stream fast path. + } } - await state.Cancellation.CancelAsync(); - state.Cancellation.Dispose(); } _activeStreams.Clear(); } diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs index 904d0a94a..d6ccabd12 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs @@ -86,6 +86,9 @@ internal static partial class Http3OriginBridge QuicServerConnection? quicConn = null; // When true, StreamBodyWriter owns originStream + quicConn release (do not dispose/release here). var streamHandedOff = false; + // Invoked by StreamBodyWriter's finally, or by the outer finally if the writer never ran + // (BeforeResponse replaced the response / cancellation before emit). + Func? pendingHandoffRelease = null; // A pooled connection can go stale between requests: MsQuic's own (server-negotiated) idle // timeout is often shorter than QuicConnectionPool's bookkeeping window, and a silently // dead connection isn't reflected by QuicServerConnection.IsClosed until it's actually used. @@ -182,40 +185,50 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio responseHeadersFrame = await Http3Frame.ReadAsync(originStream, maxPayloadBytes: server.MaxDecodedHeaderListBytes, cancellationToken); - if (responseHeadersFrame == null) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - "Expected HEADERS frame as first frame on origin response stream."); - - // RFC 9114 §9: ignore unknown/GREASE frames. DATA before HEADERS is a protocol error. - if (responseHeadersFrame.Type != Http3FrameType.Headers) + try { - if (responseHeadersFrame.Type == Http3FrameType.Data) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - "DATA frame received before response HEADERS."); - if (IsForbiddenOnRequestStream(responseHeadersFrame.Type)) + if (responseHeadersFrame == null) throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - $"Frame type 0x{responseHeadersFrame.Type:X} not permitted on request stream."); - continue; // GREASE / unknown / PRIORITY_UPDATE etc. - } + "Expected HEADERS frame as first frame on origin response stream."); - decodedResponseHeaders = QpackDecoder.Decode(responseHeadersFrame.Payload.Span); - finalStatus = ParseStatusCode(decodedResponseHeaders); + // RFC 9114 §9: ignore unknown/GREASE frames. DATA before HEADERS is a protocol error. + if (responseHeadersFrame.Type != Http3FrameType.Headers) + { + if (responseHeadersFrame.Type == Http3FrameType.Data) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + "DATA frame received before response HEADERS."); + if (IsForbiddenOnRequestStream(responseHeadersFrame.Type)) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + $"Frame type 0x{responseHeadersFrame.Type:X} not permitted on request stream."); + continue; // GREASE / unknown / PRIORITY_UPDATE etc. + } - if (finalStatus is >= 100 and < 200) - { - if (++interimCount > maxInterimResponses) - throw new Http3StreamException(Http3ErrorCode.InternalError, - $"Origin sent more than {maxInterimResponses} interim responses."); + decodedResponseHeaders = QpackDecoder.Decode(responseHeadersFrame.Payload.Span); + finalStatus = ParseStatusCode(decodedResponseHeaders); + if (finalStatus is < 100 or > 599) + throw new Http3StreamException(Http3ErrorCode.MessageError, + $"Origin response :status is missing or invalid ({finalStatus})."); - if (onInterimResponse != null) + if (finalStatus is >= 100 and < 200) { - var interim = BuildResponseFromHeaders(decodedResponseHeaders, HttpHeader.Version30); - await onInterimResponse(interim, cancellationToken); + if (++interimCount > maxInterimResponses) + throw new Http3StreamException(Http3ErrorCode.InternalError, + $"Origin sent more than {maxInterimResponses} interim responses."); + + if (onInterimResponse != null) + { + var interim = BuildResponseFromHeaders(decodedResponseHeaders, HttpHeader.Version30); + await onInterimResponse(interim, cancellationToken); + } + continue; } - continue; - } - break; + break; + } + finally + { + responseHeadersFrame?.ReturnPayload(); + } } sessionArgs.Timing?.MarkResponseHeadersReceived(); @@ -228,13 +241,16 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio if (!string.IsNullOrEmpty(altSvc)) { var entries = AltSvcParser.Parse(altSvc); - if (entries.Count > 0 && entries[0].MaxAgeSeconds > 0) + var originPort = request.GetOriginHostPort(port).Port; + foreach (var entry in entries) { - var originPort = request.GetOriginHostPort(port).Port; - var ttlSeconds = Math.Min(entries[0].MaxAgeSeconds, Http3OriginCapabilityCache.DefaultTtl.TotalSeconds * 2); + if (entry.MaxAgeSeconds <= 0) + continue; + var ttlSeconds = Math.Min(entry.MaxAgeSeconds, Http3OriginCapabilityCache.DefaultTtl.TotalSeconds * 2); var ttl = TimeSpan.FromSeconds(ttlSeconds); server.Http3OriginCapabilityCache.Set($"{sniHost}:{originPort}", - entries[0].Port == originPort ? int.MinValue : entries[0].Port, ttl); + entry.Port == originPort ? int.MinValue : entry.Port, ttl); + break; } } @@ -332,6 +348,15 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio quicConn = null; streamHandedOff = true; + var handoffReleased = 0; + pendingHandoffRelease = async () => + { + if (Interlocked.Exchange(ref handoffReleased, 1) != 0) + return; + try { await streamToClient.DisposeAsync(); } catch { /* best effort */ } + try { await QuicConnectionPool.ReleaseAsync(connToRelease); } catch { /* best effort */ } + }; + var hasBodyWriteHook = server.HasOnResponseBodyWriteSubscribers; response.StreamBodyWriter = async (clientBodyStream, ct) => @@ -414,8 +439,8 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio } finally { - try { await streamToClient.DisposeAsync(); } catch { /* best effort */ } - try { await QuicConnectionPool.ReleaseAsync(connToRelease); } catch { /* best effort */ } + if (pendingHandoffRelease != null) + await pendingHandoffRelease(); } }; @@ -492,11 +517,12 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio continue; } - if (!isForcedH3) + if (!isForcedH3 && !requestSent) { // Auto policy: the cached H3 capability is stale or unusable — evict and fall back to TCP. - // Evict by origin identity (request URI port), not the QUIC connect port, which may - // differ when Alt-Svc / SVCB advertised an alternative port. + // Only when no request bytes were written: replaying a POST/PUT after HEADERS were sent + // would double-submit. Evict by origin identity (request URI port), not the QUIC connect + // port, which may differ when Alt-Svc / SVCB advertised an alternative port. var originPort = request.GetOriginHostPort(port).Port; var hostAndPort = $"{sniHost}:{originPort}"; server.Http3OriginCapabilityCache.Evict(hostAndPort); @@ -518,7 +544,8 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio return; } - // Forced H3: surface as a 502 — never fall back silently. + // Forced H3, or Auto after the request was already on the wire: surface as a 502 — + // never silently replay a non-idempotent exchange on TCP. sessionArgs.HttpClient.Response = MakeBadGatewayResponse(ex.Message); return; } @@ -526,8 +553,12 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio } finally { - // When StreamBodyWriter owns the stream/connection, it releases on completion. - // Otherwise give up this request's stream so idle eviction is not blocked forever. + // When StreamBodyWriter owns the stream/connection, only that writer may release. + // Calling pendingHandoffRelease here races EmitSyntheticResponseAsync / the bridge body + // pump: ForwardOverQuicAsync returns before StreamBodyWriter runs, so an eager release + // disposes QuicStream under the reader (ObjectDisposedException → H2→H3 stream failure, + // observed on api.x.com 2026-10-01). Callers that replace the response without invoking + // StreamBodyWriter must drain it to Stream.Null (see H2→H3 bridge After BeforeResponse). if (!streamHandedOff && quicConn != null) await QuicConnectionPool.ReleaseAsync(quicConn); } diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs index bf15953ed..4219e223b 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs @@ -171,9 +171,10 @@ private static async Task DrainBytesAsync(QuicStream stream, CancellationToken c continue; } - // Drain remaining control frames (GOAWAY, etc.). + // Drain remaining control frames. GOAWAY means stop new streams but keep the + // control stream open (RFC 9114 §6.2.1: closing it is H3_CLOSED_CRITICAL_STREAM). if (frame.Type == Http3FrameType.GoAway) - return settings; + continue; } finally { diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs index db59052a7..776701acb 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs @@ -567,7 +567,7 @@ private static async Task HandleH3OriginFastPathAsync( // NOSONAR S107 -- Parame string normalizedPath, List<(string Name, string Value)> regularHeaders) { - var cts = new CancellationTokenSource(); + var cts = server.RentSessionCancellation(); // Link so connection teardown cancels stream waits even before FinalizeAllStreams runs. using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); var streamToken = linkedCts.Token; @@ -617,18 +617,22 @@ private static async Task HandleH3OriginFastPathAsync( // NOSONAR S107 -- Parame OriginAuthorityHost = originAuthorityHost }; + // Allocate the cold-open factory only when a pool miss actually needs a session stub. + // Warm reverse never invokes it — avoid per-stream closure+delegate on the hot path. + SessionEventArgs? coldStub = null; SessionEventArgs ColdOpenSessionFactory() { - // Cold pool miss only (after warmup the open callback is never invoked). + if (coldStub is not null) + return coldStub; var nullStream = new HttpClientStream( server, clientConnection, System.IO.Stream.Null, server.BufferPool, CancellationToken.None, rentReadBuffer: false); var stubCts = new CancellationTokenSource(); - var stub = new SessionEventArgs(server, endPoint, nullStream, null, stubCts); - stub.IsFastPath = true; - stub.CustomUpStreamProxy = authArgs.CustomUpStreamProxy; - stub.UpstreamHttpProtocol = authArgs.UpstreamHttpProtocol; - return stub; + coldStub = new SessionEventArgs(server, endPoint, nullStream, null, stubCts); + coldStub.IsFastPath = true; + coldStub.CustomUpStreamProxy = authArgs.CustomUpStreamProxy; + coldStub.UpstreamHttpProtocol = authArgs.UpstreamHttpProtocol; + return coldStub; } switch (authArgs.UpstreamHttpProtocol) @@ -708,7 +712,19 @@ await SendPreencodedResponseAsync(stream, fwd.PreencodedQpackHeaders, finally { if (Interlocked.CompareExchange(ref streamState.FinalizedFlag, 1, 0) == 0) - cts.Dispose(); + { + try + { + if (!cts.IsCancellationRequested) + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Already torn down. + } + + server.ReturnSessionCancellation(cts); + } } } diff --git a/src/Titanium.Web.Proxy/Network/HttpWebClient.cs b/src/Titanium.Web.Proxy/Network/HttpWebClient.cs index 45d75bb7f..26429223c 100644 --- a/src/Titanium.Web.Proxy/Network/HttpWebClient.cs +++ b/src/Titanium.Web.Proxy/Network/HttpWebClient.cs @@ -126,7 +126,7 @@ internal TcpServerConnection Connection /// /// Web Request. /// - public Request Request { get; } + public Request Request { get; private set; } /// /// Web Response. Created on first access so H2/H3 MITM Lite request-only work @@ -368,7 +368,16 @@ internal ValueTask ReceiveResponse(CancellationToken cancellationToken) Response.RequestMethod = Request.Method; - var statusVt = Connection.Stream.ReadResponseStatus(cancellationToken); + ValueTask statusVt; + try + { + statusVt = Connection.Stream.ReadResponseStatus(cancellationToken); + } + catch (FormatException ex) when (CanRetryMalformedStatusLine()) + { + throw MalformedStatusLine(ex); + } + if (!statusVt.IsCompletedSuccessfully) return ReceiveResponseSlowAsync(statusVt, cancellationToken); @@ -381,9 +390,9 @@ private ValueTask ReceiveResponseAfterStatus(ResponseStatusInfo? httpStatus, if (httpStatus == null) { // EOF before any response bytes: typically a stale pooled keep-alive connection. - // RetryPolicy re-runs the whole exchange; only safe when there is no body or the - // body is buffered in memory (IsBodyRead). A streamed body cannot be replayed. - if (!Request.HasBody || Request.IsBodyRead) + // Only replay-safe for idempotent methods with a replayable body (same gate as + // malformed status). A POST that already reached the origin must not be replayed. + if (CanRetryEofBeforeResponse()) throw new RetryableServerConnectionException( "Server connection was closed before any response was received."); @@ -408,10 +417,35 @@ private ValueTask ReceiveResponseAfterStatus(ResponseStatusInfo? httpStatus, private async ValueTask ReceiveResponseSlowAsync(ValueTask statusVt, CancellationToken cancellationToken) { - var httpStatus = await statusVt; + ResponseStatusInfo? httpStatus; + try + { + httpStatus = await statusVt; + } + catch (FormatException ex) when (CanRetryMalformedStatusLine()) + { + throw MalformedStatusLine(ex); + } + await ReceiveResponseAfterStatus(httpStatus, cancellationToken); } + /// + /// A status-line happens before any response bytes are committed + /// to the client, so a replay is possible when the request is replayable (no body, or buffered). + /// Unlike EOF-before-response, the origin did answer (with leftover bytes of a desynced + /// keep-alive) and has very likely processed the request, so only idempotent GET/HEAD are + /// replayed: a POST/PATCH replay could be applied twice. + /// + private bool CanRetryMalformedStatusLine() => + (!Request.HasBody || Request.IsBodyRead) + && Routing.StreamDestinationDispatch.IsIdempotentMethod(Request.Method); + + private bool CanRetryEofBeforeResponse() => CanRetryMalformedStatusLine(); + + private static RetryableServerConnectionException MalformedStatusLine(FormatException ex) => + new("Server returned a response that did not start with an HTTP status line.", ex); + private static async ValueTask ReceiveResponseAwaitHeadersAsync(ValueTask headersVt, CancellationToken cancellationToken) { @@ -453,4 +487,27 @@ internal void ResetForKeepAlive() UserData = null; } + /// + /// Rebind a pooled lite client onto a new request without allocating a new + /// / response shell (H1 terminate-lite hot path). + /// + internal void RebindForTerminateLite(Request request) + { + connection = null; + upstreamConnectionId = null; + upstreamRemoteEndPoint = null; + upstreamConnectionTiming = null; + CloseServerConnection = false; + Request = request; + if (response is not null) + response.ResetForKeepAlive(); + else + response = new Response(); + data?.Clear(); + UserData = null; + UpStreamEndPoint = null; + UpStreamEndPointIPv4 = null; + UpStreamEndPointIPv6 = null; + } + } \ No newline at end of file diff --git a/src/Titanium.Web.Proxy/Options/PolicyFamily.cs b/src/Titanium.Web.Proxy/Options/PolicyFamily.cs index f4e7951eb..d5d74d1d9 100644 --- a/src/Titanium.Web.Proxy/Options/PolicyFamily.cs +++ b/src/Titanium.Web.Proxy/Options/PolicyFamily.cs @@ -83,5 +83,20 @@ public enum PolicyFamily /// GOAWAY(PROTOCOL_ERROR) on violations. /// /// - Http2RelayValidation + Http2RelayValidation, + + /// + /// Whether HTTP/1 keep-alive retries (EOF-before-response, malformed status, mid-exchange + /// IOException) and bare CR in header values follow the strict replay-safe rules. + /// + /// (default on and + /// ) keeps today's lenient behavior and + /// records a policy breach metric when the strict rule would have fired. + /// + /// + /// (default on ) + /// refuses to replay non-idempotent methods and rejects bare CR/LF/NUL in header values. + /// + /// + Http1ReplaySafety } diff --git a/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs b/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs index 5cde2209d..2558e3fad 100644 --- a/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs +++ b/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs @@ -48,7 +48,8 @@ private ProxyPolicyModes(Dictionary modes, bool allowA headerLimits: PolicyMode.Enforce, admissionControl: PolicyMode.Enforce, http2AbuseBudget: PolicyMode.Enforce, - http2RelayValidation: PolicyMode.Enforce); + http2RelayValidation: PolicyMode.Enforce, + http1ReplaySafety: PolicyMode.Enforce); /// Returns the mode selected for . public PolicyMode this[PolicyFamily family] => modes[family]; @@ -60,7 +61,8 @@ public static ProxyPolicyModes Create( PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget, - PolicyMode http2RelayValidation = PolicyMode.Disabled) + PolicyMode http2RelayValidation = PolicyMode.Disabled, + PolicyMode http1ReplaySafety = PolicyMode.Observe) { var dict = new Dictionary { @@ -69,7 +71,8 @@ public static ProxyPolicyModes Create( [PolicyFamily.HeaderLimits] = headerLimits, [PolicyFamily.AdmissionControl] = admissionControl, [PolicyFamily.Http2AbuseBudget] = http2AbuseBudget, - [PolicyFamily.Http2RelayValidation] = http2RelayValidation + [PolicyFamily.Http2RelayValidation] = http2RelayValidation, + [PolicyFamily.Http1ReplaySafety] = http1ReplaySafety }; return new ProxyPolicyModes(dict, false); } diff --git a/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs b/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs index 1d68d6b83..fc5b4c26f 100644 --- a/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs +++ b/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs @@ -76,7 +76,8 @@ private ProxyProfileSettings() headerLimits: PolicyMode.Enforce, admissionControl: PolicyMode.Enforce, http2AbuseBudget: PolicyMode.Enforce, - http2RelayValidation: PolicyMode.Disabled), + http2RelayValidation: PolicyMode.Disabled, + http1ReplaySafety: PolicyMode.Observe), SupportedSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, BlockPrivateNetworkDestinations = false, MaxConcurrentClientConnections = null, @@ -111,7 +112,8 @@ private ProxyProfileSettings() headerLimits: PolicyMode.Observe, admissionControl: PolicyMode.Observe, http2AbuseBudget: PolicyMode.Observe, - http2RelayValidation: PolicyMode.Disabled), + http2RelayValidation: PolicyMode.Disabled, + http1ReplaySafety: PolicyMode.Observe), #pragma warning disable SYSLIB0039 // Deliberate legacy-TLS opt-in for 4.x migrators, per this profile's purpose. SupportedSslProtocols = SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, // NOSONAR S4423 - Compatible profile intentionally enables TLS 1.0/1.1 for migration #pragma warning restore SYSLIB0039 @@ -141,8 +143,12 @@ private ProxyProfileSettings() /// public static ProxyProfileSettings PublicFacing { get; } = new() { - ResourceLimits = ProxyResourceLimits.Default.WithCertificateCacheBounds( - maxCertificateCacheEntries: 4096, maxCertificateDiskCacheEntries: 50_000), + // Keep the pre-1000 stream cap. Default moved to 1000 so a single-user browser session + // (Balanced) can carry an x.com-sized fan-out; an untrusted client must not get that budget. + ResourceLimits = ProxyResourceLimits.Default + .WithMaxConcurrentStreamsPerConnection(256) + .WithCertificateCacheBounds( + maxCertificateCacheEntries: 4096, maxCertificateDiskCacheEntries: 50_000), PolicyModes = ProxyPolicyModes.AllEnforce, SupportedSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, BlockPrivateNetworkDestinations = true, diff --git a/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs b/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs index 27125b237..5716943e7 100644 --- a/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs +++ b/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs @@ -142,12 +142,13 @@ private ProxyResourceLimits() public int? MaxCertificateDiskCacheEntries { get; private init; } /// - /// Today's shipped values, carried forward as the Balanced profile's starting point - /// per the plan's rollout section: this is not a behavior change for existing traffic. Limits - /// newly introduced by the hardening plan (header aggregate bytes, decompression ratio, - /// CONTINUATION frame count, reset budget, admission cap) are set high enough that no - /// browser-generated traffic should reach them; they are expected to move once the benchmark - /// project has real numbers behind them. + /// Today's shipped values, carried forward as the Balanced profile's starting point. + /// Header, decompression, CONTINUATION, and reset budgets stay high enough that ordinary + /// browser traffic does not trip them. is 1000, + /// matching Chrome's own concurrent-stream setting: 256 reset later requests on a single + /// x.com connection (REFUSED_STREAM) while the same page succeeded with no proxy. + /// The check runs only when a stream is opened, so traffic under the old cap takes the same + /// DATA path. pins the cap back to 256. /// /// is the one deliberate exception to "unchanged /// for existing traffic": measurement showed process memory holding steady at ~100 MB @@ -167,7 +168,10 @@ private ProxyResourceLimits() maxDecodedBodyBytes: null, maxDecompressionRatio: 200, maxConcurrentClients: null, - maxConcurrentStreamsPerConnection: 256, + // Chrome advertises 1000. 256 was below the fan-out of one x.com connection once the + // inspector holds streams open for capture, so later timeline and comment requests were + // reset with REFUSED_STREAM. Workloads that stay under 256 take the same frame path. + maxConcurrentStreamsPerConnection: 1000, maxPeerInitiatedIncompleteStreamResets: 100, maxOpenHeaderBlockFrames: 128, maxOpenHeaderBlockDuration: TimeSpan.FromSeconds(10), diff --git a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs index 83a06992f..0a2427c0c 100644 --- a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs +++ b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs @@ -77,5 +77,5 @@ // file-properties version disagreed with the package it was published in. Keep both of the values // below equal to (as Major.Minor.Build.0) whenever that property changes. -[assembly: AssemblyVersion("7.0.14.0")] -[assembly: AssemblyFileVersion("7.0.14.0")] +[assembly: AssemblyVersion("7.0.15.0")] +[assembly: AssemblyFileVersion("7.0.15.0")] diff --git a/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt index 5d66f97ee..bd8abf76f 100644 --- a/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt +++ b/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt @@ -103,7 +103,7 @@ static Titanium.Web.Proxy.Models.HttpHeader.Encoding.get -> System.Text.Encoding static Titanium.Web.Proxy.Models.ProxyAuthenticationContext.Failed() -> Titanium.Web.Proxy.Models.ProxyAuthenticationContext! static Titanium.Web.Proxy.Models.ProxyAuthenticationContext.Succeeded() -> Titanium.Web.Proxy.Models.ProxyAuthenticationContext! static Titanium.Web.Proxy.Options.ProxyPolicyModes.AllEnforce.get -> Titanium.Web.Proxy.Options.ProxyPolicyModes! -static Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode bodyBudget, Titanium.Web.Proxy.Options.PolicyMode decompressionRatio, Titanium.Web.Proxy.Options.PolicyMode headerLimits, Titanium.Web.Proxy.Options.PolicyMode admissionControl, Titanium.Web.Proxy.Options.PolicyMode http2AbuseBudget, Titanium.Web.Proxy.Options.PolicyMode http2RelayValidation = Titanium.Web.Proxy.Options.PolicyMode.Disabled) -> Titanium.Web.Proxy.Options.ProxyPolicyModes! +static Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode bodyBudget, Titanium.Web.Proxy.Options.PolicyMode decompressionRatio, Titanium.Web.Proxy.Options.PolicyMode headerLimits, Titanium.Web.Proxy.Options.PolicyMode admissionControl, Titanium.Web.Proxy.Options.PolicyMode http2AbuseBudget, Titanium.Web.Proxy.Options.PolicyMode http2RelayValidation = Titanium.Web.Proxy.Options.PolicyMode.Disabled, Titanium.Web.Proxy.Options.PolicyMode http1ReplaySafety = Titanium.Web.Proxy.Options.PolicyMode.Observe) -> Titanium.Web.Proxy.Options.ProxyPolicyModes! static Titanium.Web.Proxy.Options.ProxyProfileSettings.Balanced.get -> Titanium.Web.Proxy.Options.ProxyProfileSettings! static Titanium.Web.Proxy.Options.ProxyProfileSettings.For(Titanium.Web.Proxy.Options.ProxyProfile profile) -> Titanium.Web.Proxy.Options.ProxyProfileSettings! static Titanium.Web.Proxy.Options.ProxyProfileSettings.LegacyCompatible.get -> Titanium.Web.Proxy.Options.ProxyProfileSettings! @@ -670,6 +670,7 @@ Titanium.Web.Proxy.Options.PolicyFamily.DecompressionRatio = 1 -> Titanium.Web.P Titanium.Web.Proxy.Options.PolicyFamily.HeaderLimits = 2 -> Titanium.Web.Proxy.Options.PolicyFamily Titanium.Web.Proxy.Options.PolicyFamily.Http2AbuseBudget = 4 -> Titanium.Web.Proxy.Options.PolicyFamily Titanium.Web.Proxy.Options.PolicyFamily.Http2RelayValidation = 5 -> Titanium.Web.Proxy.Options.PolicyFamily +Titanium.Web.Proxy.Options.PolicyFamily.Http1ReplaySafety = 6 -> Titanium.Web.Proxy.Options.PolicyFamily Titanium.Web.Proxy.Options.PolicyMode Titanium.Web.Proxy.Options.PolicyMode.Disabled = 0 -> Titanium.Web.Proxy.Options.PolicyMode Titanium.Web.Proxy.Options.PolicyMode.Enforce = 2 -> Titanium.Web.Proxy.Options.PolicyMode diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj index 4808f1b3e..93c989b46 100644 --- a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj +++ b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj @@ -13,7 +13,7 @@ - 7.0.14 + 7.0.15