diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index e948550..0000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,226 +0,0 @@ -name: Benchmark - -on: - workflow_dispatch: - pull_request: - branches: [ main ] - -jobs: - - parsers: - - name: Parser Benchmarks - - if: github.event_name == 'pull_request' - - runs-on: ubuntu-latest - - permissions: - pull-requests: write - - steps: - - - name: Checkout - uses: actions/checkout@v4 - - - name: Download .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0 - - - name: Run parser benchmarks - run: dotnet run -c Release -- --filter '*FlexibleParserBenchmark*' '*UltraHardenedParserBenchmark*' - working-directory: Benchmarks - - - name: Upload results - uses: actions/upload-artifact@v4 - if: always() - with: - name: parser-benchmark-results - path: Benchmarks/BenchmarkDotNet.Artifacts/results/ - - - name: Combine benchmark JSON results - run: | - jq -s '{ Benchmarks: [ .[].Benchmarks[] ] }' \ - Benchmarks/BenchmarkDotNet.Artifacts/results/*-report-full-compressed.json \ - > combined-benchmarks.json - - - name: Add allocation metrics to combined results - run: | - python3 -c " - import json, glob - files = glob.glob('Benchmarks/BenchmarkDotNet.Artifacts/results/*-report-full-compressed.json') - alloc_entries = [] - for f in files: - with open(f) as fh: - data = json.load(fh) - for b in data.get('Benchmarks', []): - alloc = b.get('Memory', {}).get('BytesAllocatedPerOperation', 0) - if alloc is not None: - alloc_entries.append({ - 'FullName': b['FullName'] + '.Allocated', - 'Statistics': {'Mean': float(alloc), 'StandardDeviation': 0.0}, - 'Namespace': b.get('Namespace', ''), - 'Type': b.get('Type', ''), - 'Method': b.get('Method', '') + '_Allocated' - }) - with open('combined-benchmarks.json') as fh: - combined = json.load(fh) - combined['Benchmarks'].extend(alloc_entries) - with open('combined-benchmarks.json', 'w') as fh: - json.dump(combined, fh) - " - - - name: Compare against baseline - uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1 - with: - tool: 'benchmarkdotnet' - output-file-path: combined-benchmarks.json - github-token: ${{ secrets.GITHUB_TOKEN }} - gh-pages-branch: gh-pages - benchmark-data-dir-path: benchmarks - max-items-in-chart: 15 - alert-threshold: '115%' - fail-on-alert: true - comment-on-alert: true - comment-always: true - summary-always: true - - full: - - name: Full Benchmarks - - if: github.event_name == 'workflow_dispatch' - - runs-on: ubuntu-latest - - permissions: - contents: write - actions: write - - steps: - - - name: Checkout - uses: actions/checkout@v4 - - - name: Download .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0 - - - name: Run all benchmarks - run: dotnet run -c Release -- --filter '*FlexibleParserBenchmark*' '*UltraHardenedParserBenchmark*' - working-directory: Benchmarks - - - name: Upload results - uses: actions/upload-artifact@v4 - if: always() - with: - name: full-benchmark-results - path: Benchmarks/BenchmarkDotNet.Artifacts/results/ - - - name: Combine benchmark JSON results - run: | - jq -s '{ Benchmarks: [ .[].Benchmarks[] ] }' \ - Benchmarks/BenchmarkDotNet.Artifacts/results/*-report-full-compressed.json \ - > combined-benchmarks.json - - - name: Add allocation metrics to combined results - run: | - python3 -c " - import json, glob - files = glob.glob('Benchmarks/BenchmarkDotNet.Artifacts/results/*-report-full-compressed.json') - alloc_entries = [] - for f in files: - with open(f) as fh: - data = json.load(fh) - for b in data.get('Benchmarks', []): - alloc = b.get('Memory', {}).get('BytesAllocatedPerOperation', 0) - if alloc is not None: - alloc_entries.append({ - 'FullName': b['FullName'] + '.Allocated', - 'Statistics': {'Mean': float(alloc), 'StandardDeviation': 0.0}, - 'Namespace': b.get('Namespace', ''), - 'Type': b.get('Type', ''), - 'Method': b.get('Method', '') + '_Allocated' - }) - with open('combined-benchmarks.json') as fh: - combined = json.load(fh) - combined['Benchmarks'].extend(alloc_entries) - with open('combined-benchmarks.json', 'w') as fh: - json.dump(combined, fh) - " - - - name: Compare and update baseline - uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1 - with: - tool: 'benchmarkdotnet' - output-file-path: combined-benchmarks.json - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: ${{ github.ref == 'refs/heads/main' }} - gh-pages-branch: gh-pages - benchmark-data-dir-path: benchmarks - max-items-in-chart: 15 - alert-threshold: '115%' - # This job publishes the baseline; it must not fail on noise (the FastConfig - # runs only 1-3 iterations). Regressions are still surfaced via the summary. - fail-on-alert: false - comment-on-alert: true - summary-always: true - - - name: Clean stale benchmark entries from gh-pages - if: ${{ always() && github.ref == 'refs/heads/main' }} - run: | - git fetch origin gh-pages - git worktree add /tmp/gh-pages gh-pages - python3 -c " - import json, pathlib, sys - p = pathlib.Path('/tmp/gh-pages/benchmarks/data.js') - if not p.exists(): - sys.exit(0) - raw = p.read_text() - prefix = 'window.BENCHMARK_DATA = ' - if not raw.startswith(prefix): - sys.exit(0) - data = json.loads(raw[len(prefix):]) - # Benchmark classes removed from the suite (precise prefixes so - # UltraHardenedParserBenchmark is never matched). - stale_prefixes = ( - 'Benchmarks.HardenedParserBenchmark.', - 'Benchmarks.RequestSemanticsBenchmark.', - 'Benchmarks.AllSemanticChecksBenchmark.', - ) - changed = False - for key, entries in data.get('entries', {}).items(): - for entry in entries: - before = len(entry.get('benches', [])) - entry['benches'] = [ - b for b in entry.get('benches', []) - if not b.get('name', '').startswith(stale_prefixes) - ] - if len(entry['benches']) != before: - changed = True - if not changed: - print('No stale entries found.') - sys.exit(0) - p.write_text(prefix + json.dumps(data)) - print('Cleaned stale benchmark entries.') - " - cd /tmp/gh-pages - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if git diff --quiet; then - echo "No changes to commit." - else - git add benchmarks/data.js - git commit -m "Clean stale benchmark entries from data.js" - git push origin gh-pages - fi - cd - - git worktree remove /tmp/gh-pages - - - name: Rebuild docs site - if: ${{ always() && github.ref == 'refs/heads/main' }} - run: gh workflow run "Deploy Docs to GitHub Pages" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/package-native.yml b/.github/workflows/package-native.yml index f1dd4bb..1c50cf5 100644 --- a/.github/workflows/package-native.yml +++ b/.github/workflows/package-native.yml @@ -11,6 +11,11 @@ on: description: Push the packed NuGet to NuGet.org type: boolean default: false + workflow_call: # called by release.yml to publish alongside the other packages + inputs: + publish: + type: boolean + default: false push: tags: - 'native-v*' @@ -111,20 +116,23 @@ jobs: - name: Resolve package version id: ver - # native-vX.Y.Z tag -> X.Y.Z (a release); otherwise a CI prerelease. + # tag native-vX.Y.Z -> X.Y.Z; publish (dispatch/release call) -> the csproj ; + # PR smoke test -> a throwaway CI prerelease. run: | ref='${{ github.ref }}' if [[ "$ref" == refs/tags/native-v* ]]; then - echo "version=${ref#refs/tags/native-v}" >> "$GITHUB_OUTPUT" + echo "args=-p:Version=${ref#refs/tags/native-v}" >> "$GITHUB_OUTPUT" + elif [[ "${{ inputs.publish }}" == "true" ]]; then + echo "args=" >> "$GITHUB_OUTPUT" else - echo "version=0.1.0-ci.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + echo "args=-p:Version=0.0.0-ci.${{ github.run_number }}" >> "$GITHUB_OUTPUT" fi - name: Pack run: > dotnet pack bindings/dotnet/Glyph11.Native/Glyph11.Native.csproj -c Release -p:ContinuousIntegrationBuild=true - -p:Version=${{ steps.ver.outputs.version }} + ${{ steps.ver.outputs.args }} -o artifacts - name: Upload package @@ -140,7 +148,7 @@ jobs: name: Publish to NuGet.org needs: pack # Only on a native-v* tag, or an explicit dispatch with publish=true. - if: startsWith(github.ref, 'refs/tags/native-v') || (github.event_name == 'workflow_dispatch' && inputs.publish) + if: startsWith(github.ref, 'refs/tags/native-v') || ((github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.publish) runs-on: ubuntu-latest environment: name: production diff --git a/.github/workflows/package-pico.yml b/.github/workflows/package-pico.yml index b8c7342..6bc84a4 100644 --- a/.github/workflows/package-pico.yml +++ b/.github/workflows/package-pico.yml @@ -11,6 +11,11 @@ on: description: Push the packed NuGet to NuGet.org type: boolean default: false + workflow_call: # called by release.yml to publish alongside the other packages + inputs: + publish: + type: boolean + default: false push: tags: - 'pico-v*' @@ -111,20 +116,23 @@ jobs: - name: Resolve package version id: ver - # pico-vX.Y.Z tag -> X.Y.Z (a release); otherwise a CI prerelease. + # tag pico-vX.Y.Z -> X.Y.Z; publish (dispatch/release call) -> the csproj ; + # PR smoke test -> a throwaway CI prerelease. run: | ref='${{ github.ref }}' if [[ "$ref" == refs/tags/pico-v* ]]; then - echo "version=${ref#refs/tags/pico-v}" >> "$GITHUB_OUTPUT" + echo "args=-p:Version=${ref#refs/tags/pico-v}" >> "$GITHUB_OUTPUT" + elif [[ "${{ inputs.publish }}" == "true" ]]; then + echo "args=" >> "$GITHUB_OUTPUT" else - echo "version=0.0.1-ci.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + echo "args=-p:Version=0.0.1-ci.${{ github.run_number }}" >> "$GITHUB_OUTPUT" fi - name: Pack run: > dotnet pack bindings/dotnet/Glyph11.Pico/Glyph11.Pico.csproj -c Release -p:ContinuousIntegrationBuild=true - -p:Version=${{ steps.ver.outputs.version }} + ${{ steps.ver.outputs.args }} -o artifacts - name: Upload package @@ -140,7 +148,7 @@ jobs: name: Publish to NuGet.org needs: pack # Only on a pico-v* tag, or an explicit dispatch with publish=true. - if: startsWith(github.ref, 'refs/tags/pico-v') || (github.event_name == 'workflow_dispatch' && inputs.publish) + if: startsWith(github.ref, 'refs/tags/pico-v') || ((github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.publish) runs-on: ubuntu-latest environment: name: production diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9406d73..c4585de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,10 @@ name: Release +# Publishes all three NuGet packages at their csproj versions: +# Glyph11 (managed) here, and Glyph11.Native / Glyph11.Pico via their package +# workflows (which run the full native build matrices, then pack + push). +# Every push uses --skip-duplicate, so re-running only publishes versions not yet +# on NuGet.org — bump a package's and re-run to release it. on: workflow_dispatch: @@ -9,15 +14,11 @@ permissions: jobs: - publish: - - name: Build & Publish Packages - + managed: + name: Glyph11 (managed) runs-on: windows-latest - environment: name: production - steps: - name: Checkout source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -32,9 +33,6 @@ jobs: 9.0 10.0 - - name: Setup NuGet CLI - uses: NuGet/setup-nuget@323ab0502cd38fdc493335025a96c8fdb0edc71f # v2 - - name: Restore dependencies run: dotnet restore working-directory: src @@ -56,3 +54,19 @@ jobs: env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: dotnet nuget push *.nupkg -k "$env:NUGET_API_KEY" -s https://api.nuget.org/v3/index.json --skip-duplicate + + # Build the 6-RID native matrix, pack, and publish Glyph11.Native at its csproj version. + native: + name: Glyph11.Native + uses: ./.github/workflows/package-native.yml + with: + publish: true + secrets: inherit + + # Build the 6-RID native matrix, pack, and publish Glyph11.Pico at its csproj version. + pico: + name: Glyph11.Pico + uses: ./.github/workflows/package-pico.yml + with: + publish: true + secrets: inherit diff --git a/Benchmarks/BenchmarkData.cs b/Benchmarks/BenchmarkData.cs deleted file mode 100644 index ec27955..0000000 --- a/Benchmarks/BenchmarkData.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Buffers; -using System.Text; -using Glyph11.Utils; - -namespace Benchmarks; - -public static class BenchmarkData -{ - /// - /// Builds a small (~80B) HTTP/1.1 request header with 2 headers. - /// - public static byte[] BuildSmallHeader() - { - return Encoding.ASCII.GetBytes( - "GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n" + - "Host: localhost\r\n" + - "Content-Length: 100\r\n" + - "Server: GenHTTP\r\n\r\n"); - } - - /// - /// Builds a valid HTTP/1.1 request header block of approximately targetBytes size. - /// Fills with realistic headers until the target is reached. - /// - public static byte[] BuildHeader(int targetBytes) - { - var sb = new StringBuilder(targetBytes + 128); - sb.Append("GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n"); - sb.Append("Host: localhost\r\n"); - - int index = 0; - while (sb.Length < targetBytes - 4) // leave room for final \r\n - { - // Pad value to fill space — realistic long header values - string name = $"X-Header-{index++}"; - int remaining = targetBytes - sb.Length - name.Length - 4; // ": " + value + "\r\n" - int valueLen = Math.Min(Math.Max(remaining, 1), 200); - string value = new string('A', valueLen); - sb.Append(name).Append(": ").Append(value).Append("\r\n"); - } - - sb.Append("\r\n"); - return Encoding.ASCII.GetBytes(sb.ToString()); - } - - /// - /// Splits a byte array into exactly 3 segments. - /// - public static ReadOnlySequence ToThreeSegments(byte[] data) - { - int split1 = data.Length / 3; - int split2 = 2 * data.Length / 3; - - var first = new BufferSegment(data.AsMemory(0, split1)); - var last = first.Append(data.AsMemory(split1, split2 - split1)).Append(data.AsMemory(split2)); - - return new ReadOnlySequence(first, 0, last, last.Memory.Length); - } -} diff --git a/Benchmarks/Benchmarks.csproj b/Benchmarks/Benchmarks.csproj index 622c2b1..c410783 100644 --- a/Benchmarks/Benchmarks.csproj +++ b/Benchmarks/Benchmarks.csproj @@ -1,18 +1,18 @@ - + net10.0 exe + enable + enable false - - - - - - + + + + diff --git a/Benchmarks/FlexibleParserBenchmark.cs b/Benchmarks/FlexibleParserBenchmark.cs deleted file mode 100644 index 1c9657c..0000000 --- a/Benchmarks/FlexibleParserBenchmark.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Buffers; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Columns; -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Exporters; -using BenchmarkDotNet.Exporters.Json; -using BenchmarkDotNet.Jobs; -using BenchmarkDotNet.Loggers; -using BenchmarkDotNet.Running; -using GenHTTP.Types; -using Glyph11.Parser.FlexibleParser; - -namespace Benchmarks; - -public static class Program -{ - public sealed class FastConfig : ManualConfig - { - public FastConfig() - { - AddJob(Job.Default - .WithMinIterationCount(1) - .WithMaxIterationCount(3)); - - // optional but useful (removes your other warnings) - AddLogger(ConsoleLogger.Default); - AddExporter(MarkdownExporter.Default); - AddExporter(JsonExporter.FullCompressed); - AddColumnProvider(DefaultColumnProviders.Instance); - } - } - public static void Main(string[] args) - { - BenchmarkSwitcher - .FromAssembly(typeof(Program).Assembly) - .Run(args, new FastConfig()); - } -} - -[MemoryDiagnoser] -public class FlexibleParserBenchmark -{ - private readonly Request _into = new(); - - // ---- Small (~80B) ---- - - private readonly ReadOnlySequence _buffer = - new(("GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n"u8 + - "Content-Length: 100\r\n"u8 + - "Server: GenHTTP\r\n\r\n"u8).ToArray()); - - private ReadOnlySequence _segmentedBuffer = CreateMultiSegment(); - - private ReadOnlyMemory _memory; - - // ---- Large headers: 4KB, 32KB ---- - - private static readonly byte[] _header4K = BenchmarkData.BuildHeader(4096); - private static readonly byte[] _header32K = BenchmarkData.BuildHeader(32768); - - private ReadOnlyMemory _rom4K; - private ReadOnlyMemory _rom32K; - - private ReadOnlySequence _seg4K; - private ReadOnlySequence _seg32K; - - public FlexibleParserBenchmark() - { - _memory = _buffer.ToArray(); - - _rom4K = _header4K; - _rom32K = _header32K; - - _seg4K = BenchmarkData.ToThreeSegments(_header4K); - _seg32K = BenchmarkData.ToThreeSegments(_header32K); - } - - private static ReadOnlySequence CreateMultiSegment() - { - var seg1 = "GET /route?p1=1&p2=2&p3=3&p4=4 HT"u8.ToArray(); - var seg2 = "TP/1.1\r\nContent-Length: 100\r\nServer: "u8.ToArray(); - var seg3 = "GenHTTP\r\n\r\n"u8.ToArray(); - - var first = new Glyph11.Utils.BufferSegment(seg1); - var last = first.Append(seg2).Append(seg3); - - return new ReadOnlySequence(first, 0, last, last.Memory.Length); - } - - // ---- Small: ROM / MultiSegment ---- - - [Benchmark] - public void Small_ROM() - { - _into.Reset(); - FlexibleParser.TryExtractFullHeaderReadOnlyMemory(ref _memory, _into.Source, out _); - } - - [Benchmark] - public void Small_MultiSegment() - { - _into.Reset(); - FlexibleParser.TryExtractFullHeader(ref _segmentedBuffer, _into.Source, out _); - } - - // ---- 4KB ---- - - [Benchmark] - public void Header4K_ROM() - { - _into.Reset(); - FlexibleParser.TryExtractFullHeaderReadOnlyMemory(ref _rom4K, _into.Source, out _); - } - - [Benchmark] - public void Header4K_MultiSegment() - { - _into.Reset(); - FlexibleParser.TryExtractFullHeader(ref _seg4K, _into.Source, out _); - } - - // ---- 32KB ---- - - [Benchmark] - public void Header32K_ROM() - { - _into.Reset(); - FlexibleParser.TryExtractFullHeaderReadOnlyMemory(ref _rom32K, _into.Source, out _); - } - - [Benchmark] - public void Header32K_MultiSegment() - { - _into.Reset(); - FlexibleParser.TryExtractFullHeader(ref _seg32K, _into.Source, out _); - } -} diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs new file mode 100644 index 0000000..2b8715e --- /dev/null +++ b/Benchmarks/Program.cs @@ -0,0 +1,193 @@ +// Reproduces the benchmark tables shown on the docs site: the three packages +// (Glyph11 managed, Glyph11.Native, Glyph11.Pico) on contiguous request parsing, +// multi-segment request parsing, and chunked-body decoding. Best-of-5, ns/op. +// +// # build the native cores, then point at them and run: +// cmake -S core -B core/build -DGLYPH11_BUILD_TESTS=OFF && cmake --build core/build +// cmake -S bindings/dotnet/Glyph11.Pico/native -B pico-build && cmake --build pico-build +// GLYPH11_NATIVE_PATH="$PWD/core/build/libglyph11.so" \ +// GLYPH11_PICO_NATIVE_PATH="$PWD/pico-build/libglyph11pico.so" \ +// dotnet run -c Release --project Benchmarks +// +// (Glyph11 — pure managed — needs no native library.) + +using System.Buffers; +using System.Diagnostics; +using Glyph11.Native; +using Glyph11.Pico; +using Glyph11.Protocol; +using Glyph11.Parser; +using Glyph11.Parser.UltraHardened; + +// ---- payloads (identical bytes for every parser) ---- +byte[] small = Payloads.Small; +(string label, byte[] bytes)[] requests = +{ + ("~95 B", small), + ("4 KB", Payloads.Header(4096)), + ("32 KB", Payloads.Header(32768)), +}; +(string label, byte[] bytes)[] chunked = +{ + ("256 B", Payloads.Chunked(256)), + ("4 KB", Payloads.Chunked(4096)), + ("32 KB", Payloads.Chunked(32768)), +}; + +// ---- policy + reusable storage (no per-request allocation in the parse itself) ---- +var managedLimits = ParserLimits.Default with { MaxHeaderCount = 200, MaxTotalHeaderBytes = 64 * 1024 }; +var nativeLimits = Glyph11Limits.Default; +nativeLimits.MaxHeaderCount = 200; +nativeLimits.MaxTotalHeaderBytes = 64 * 1024; + +var headers = new Glyph11Field[256]; +var query = new Glyph11Field[256]; +var picoRequest = new BinaryRequest(); +var managedRequest = new BinaryRequest(); +var chunkOutput = new byte[64 * 1024]; + +long Iters(int len) => len < 1024 ? 2_000_000 : len < 16_384 ? 500_000 : 100_000; + +// ---- contiguous request parsing ---- +Console.WriteLine("Request header parsing — contiguous (ns/op, lower is better)"); +Table(requests, (label, bytes) => +{ + var rom = (ReadOnlyMemory)bytes; + long it = Iters(bytes.Length); + double glyph = Best(it, () => { managedRequest.Clear(); var r = rom; UltraHardenedParser.TryExtractFullHeaderROM(ref r, managedRequest, in managedLimits, out _); }); + double native = Best(it, () => Glyph11Parser.Parse(bytes, headers, query, nativeLimits, out _)); + double pico = Best(it, () => PicoParser.TryParse(rom, picoRequest, out _)); + return (glyph, native, pico); +}); + +// ---- multi-segment request parsing (3 segments, linearized per request, then parsed) ---- +Console.WriteLine("\nRequest header parsing — multi-segment"); +Table(requests, (label, bytes) => +{ + var seq = ThreeSegments(bytes); + int len = bytes.Length; + long it = Iters(len); + double glyph = Best(it, () => { managedRequest.Clear(); var s = seq; UltraHardenedParser.TryExtractFullHeaderValidated(ref s, managedRequest, in managedLimits, out _); }); + double native = Best(it, () => { var buf = new byte[len]; seq.CopyTo(buf); Glyph11Parser.Parse(buf, headers, query, nativeLimits, out _); }); + double pico = Best(it, () => { var buf = new byte[len]; seq.CopyTo(buf); PicoParser.TryParse(buf, picoRequest, out _); }); + return (glyph, native, pico); +}); + +// ---- chunked body decoding (Glyph11 == Pico, which reuses ChunkedBodyStream) ---- +Console.WriteLine("\nChunked body decoding (Glyph11 / Pico share the decoder)"); +Console.WriteLine($" {"decoded",-8}{"Glyph11/Pico",16}{"Glyph11.Native",16}"); +foreach (var (label, body) in chunked) +{ + long it = body.Length < 4096 ? 1_000_000 : body.Length < 32_768 ? 300_000 : 50_000; + double glyph = Best(it, () => DecodeManaged(body, chunkOutput)); + double native = Best(it, () => DecodeNative(body, chunkOutput)); + Console.WriteLine($" {label,-8}{glyph,13:F0} ns{native,13:F0} ns"); +} + +// ───────────────────────────────────────────────────────────────────────────── +void Table((string label, byte[] bytes)[] cases, Func run) +{ + Console.WriteLine($" {"payload",-8}{"Glyph11",13}{"Glyph11.Native",17}{"Glyph11.Pico",15}"); + foreach (var (label, bytes) in cases) + { + var (g, n, p) = run(label, bytes); + Console.WriteLine($" {label,-8}{g,10:F0} ns{n,14:F0} ns{p,12:F0} ns"); + } +} + +static void DecodeManaged(byte[] body, byte[] output) +{ + var stream = new ChunkedBodyStream(); + int inOff = 0, outOff = 0; + while (true) + { + var r = stream.TryReadChunk(body.AsSpan(inOff), out int consumed, out int dataOff, out int dataLen); + if (r != ChunkResult.Chunk) break; + body.AsSpan(inOff + dataOff, dataLen).CopyTo(output.AsSpan(outOff)); + outOff += dataLen; + inOff += consumed; + } +} + +static void DecodeNative(byte[] body, byte[] output) +{ + Glyph11Chunked.Init(out var decoder); + Glyph11Chunked.Decode(ref decoder, body, output, out _, out _); +} + +// best of N timed trials (after warmup) — filters scheduling / turbo interference +static double Best(long iters, Action body) +{ + for (long i = 0; i < iters / 10 + 1; i++) body(); + double best = double.MaxValue; + for (int t = 0; t < 5; t++) + { + var sw = Stopwatch.StartNew(); + for (long i = 0; i < iters; i++) body(); + sw.Stop(); + double ns = sw.Elapsed.TotalNanoseconds / iters; + if (ns < best) best = ns; + } + return best; +} + +static ReadOnlySequence ThreeSegments(byte[] data) +{ + int s1 = data.Length / 3, s2 = 2 * data.Length / 3; + var first = new Seg(data.AsMemory(0, s1)); + var last = first.Append(data.AsMemory(s1, s2 - s1)).Append(data.AsMemory(s2)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); +} + +sealed class Seg : ReadOnlySequenceSegment +{ + public Seg(ReadOnlyMemory memory) => Memory = memory; + public Seg Append(ReadOnlyMemory memory) + { + var next = new Seg(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } +} + +// Identical payload bytes to the docs-site benchmark. +static class Payloads +{ + public static readonly byte[] Small = System.Text.Encoding.ASCII.GetBytes( + "GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n" + + "Host: localhost\r\nContent-Length: 100\r\nServer: Glyph11\r\n\r\n"); + + // A request whose header block is ~`target` bytes (many ~200-byte header values). + public static byte[] Header(int target) + { + var sb = new System.Text.StringBuilder(target + 128); + sb.Append("GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\nHost: localhost\r\n"); + int i = 0; + while (sb.Length < target - 4) + { + string name = $"X-Header-{i++}"; + int remaining = target - sb.Length - name.Length - 4; + int vlen = Math.Min(Math.Max(remaining, 1), 200); + sb.Append(name).Append(": ").Append('A', vlen).Append("\r\n"); + } + sb.Append("\r\n"); + return System.Text.Encoding.ASCII.GetBytes(sb.ToString()); + } + + // A chunked transfer-encoding body whose decoded length is `decodedSize` (512-byte chunks). + public static byte[] Chunked(int decodedSize, int chunkSize = 512) + { + var body = new byte[decodedSize]; + for (int i = 0; i < decodedSize; i++) body[i] = (byte)(i % 251); + var outp = new ArrayBufferWriter(); + for (int i = 0; i < decodedSize; i += chunkSize) + { + int c = Math.Min(chunkSize, decodedSize - i); + outp.Write(System.Text.Encoding.ASCII.GetBytes($"{c:x}\r\n")); + outp.Write(body.AsSpan(i, c)); + outp.Write("\r\n"u8); + } + outp.Write("0\r\n\r\n"u8); + return outp.WrittenSpan.ToArray(); + } +} diff --git a/Benchmarks/README.md b/Benchmarks/README.md new file mode 100644 index 0000000..f007418 --- /dev/null +++ b/Benchmarks/README.md @@ -0,0 +1,30 @@ +# Benchmarks + +Reproduces the benchmark tables on the [docs site](https://dotnet-web-stack.github.io/Glyph11/benchmarks.html): +the three packages — **Glyph11** (managed), **Glyph11.Native**, **Glyph11.Pico** — on + +- **request header parsing** (contiguous and multi-segment), and +- **chunked body decoding** + +across `~95 B` / `4 KB` / `32 KB` payloads, best-of-5, ns/op. The payload bytes are built +in-process and are identical to what the site reports. + +## Run + +`Glyph11.Native` and `Glyph11.Pico` need their native libraries on the load path (the NuGet +packages bundle them; in-repo, build the cores and point at them): + +```sh +cmake -S core -B core/build -DGLYPH11_BUILD_TESTS=OFF && cmake --build core/build +cmake -S bindings/dotnet/Glyph11.Pico/native -B pico-build && cmake --build pico-build + +GLYPH11_NATIVE_PATH="$PWD/core/build/libglyph11.so" \ +GLYPH11_PICO_NATIVE_PATH="$PWD/pico-build/libglyph11pico.so" \ + dotnet run -c Release --project Benchmarks +``` + +(`Glyph11` — pure managed — needs no native library; its columns work with no env vars.) + +Numbers vary run-to-run and by hardware; treat them as relative. For the lowest `linux-x64` +native numbers, build the cores with AVX2/SSE4.2 (`-DGLYPH11_X86_AVX2=ON` / +`-DGLYPH11_PICO_X86_AVX2=ON`) as the shipped packages do. diff --git a/Benchmarks/UltraHardenedParserBenchmark.cs b/Benchmarks/UltraHardenedParserBenchmark.cs deleted file mode 100644 index f20da61..0000000 --- a/Benchmarks/UltraHardenedParserBenchmark.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Buffers; -using BenchmarkDotNet.Attributes; -using GenHTTP.Types; -using Glyph11.Parser; -using Glyph11.Parser.UltraHardened; - -namespace Benchmarks; - -[MemoryDiagnoser] -public class UltraHardenedParserBenchmark -{ - private readonly Request _into = new(); - - private static readonly ParserLimits Limits = ParserLimits.Default with { MaxTotalHeaderBytes = 64 * 1024, MaxHeaderCount = 200 }; - - // ---- Small (~80B) ---- - - private readonly ReadOnlySequence _buffer = - new(("GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n"u8 + - "Host: localhost\r\n"u8 + - "Content-Length: 100\r\n"u8 + - "Server: GenHTTP\r\n\r\n"u8).ToArray()); - - private ReadOnlySequence _segmentedBuffer = CreateMultiSegment(); - - private ReadOnlyMemory _memory; - - // ---- Large headers: 4KB, 32KB ---- - - private static readonly byte[] _header4K = BenchmarkData.BuildHeader(4096); - private static readonly byte[] _header32K = BenchmarkData.BuildHeader(32768); - - private ReadOnlyMemory _rom4K; - private ReadOnlyMemory _rom32K; - - private ReadOnlySequence _seg4K; - private ReadOnlySequence _seg32K; - - public UltraHardenedParserBenchmark() - { - _memory = _buffer.ToArray(); - - _rom4K = _header4K; - _rom32K = _header32K; - - _seg4K = BenchmarkData.ToThreeSegments(_header4K); - _seg32K = BenchmarkData.ToThreeSegments(_header32K); - } - - private static ReadOnlySequence CreateMultiSegment() - { - var seg1 = "GET /route?p1=1&p2=2&p3=3&p4=4 HT"u8.ToArray(); - var seg2 = "TP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\nServer: "u8.ToArray(); - var seg3 = "GenHTTP\r\n\r\n"u8.ToArray(); - - var first = new Glyph11.Utils.BufferSegment(seg1); - var last = first.Append(seg2).Append(seg3); - - return new ReadOnlySequence(first, 0, last, last.Memory.Length); - } - - // ---- Small: ROM / MultiSegment ---- - - [Benchmark] - public void Small_ROM() - { - _into.Reset(); - UltraHardenedParser.TryExtractFullHeaderROM(ref _memory, _into.Source, in Limits, out _); - } - - [Benchmark] - public void Small_MultiSegment() - { - _into.Reset(); - UltraHardenedParser.TryExtractFullHeaderValidated(ref _segmentedBuffer, _into.Source, in Limits, out _); - } - - // ---- 4KB ---- - - [Benchmark] - public void Header4K_ROM() - { - _into.Reset(); - UltraHardenedParser.TryExtractFullHeaderROM(ref _rom4K, _into.Source, in Limits, out _); - } - - [Benchmark] - public void Header4K_MultiSegment() - { - _into.Reset(); - UltraHardenedParser.TryExtractFullHeaderValidated(ref _seg4K, _into.Source, in Limits, out _); - } - - // ---- 32KB ---- - - [Benchmark] - public void Header32K_ROM() - { - _into.Reset(); - UltraHardenedParser.TryExtractFullHeaderROM(ref _rom32K, _into.Source, in Limits, out _); - } - - [Benchmark] - public void Header32K_MultiSegment() - { - _into.Reset(); - UltraHardenedParser.TryExtractFullHeaderValidated(ref _seg32K, _into.Source, in Limits, out _); - } -} \ No newline at end of file diff --git a/Examples/Glyph11.Example/Glyph11.Example.csproj b/Examples/Glyph11.Example/Glyph11.Example.csproj new file mode 100644 index 0000000..69417ad --- /dev/null +++ b/Examples/Glyph11.Example/Glyph11.Example.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + + + + diff --git a/Examples/Glyph11.Example/Program.cs b/Examples/Glyph11.Example/Program.cs new file mode 100644 index 0000000..46062bc --- /dev/null +++ b/Examples/Glyph11.Example/Program.cs @@ -0,0 +1,238 @@ +// Glyph11 (pure managed) — every option, end to end. +// +// dotnet run --project Examples/Glyph11.Example +// +// Glyph11 is dependency-free and runs anywhere .NET runs — no native library needed. + +using System.Buffers; +using System.Text; +using Glyph11; // HttpParseException +using Glyph11.Protocol; // BinaryRequest, KeyValueList +using Glyph11.Parser; // ParserLimits, ChunkedBodyStream +using Glyph11.Parser.UltraHardened; // UltraHardenedParser + +// A BinaryRequest is reusable storage — allocate it once and Clear() it between requests +// to stay allocation-free across a connection (see Example 6). +var request = new BinaryRequest(); + +ContiguousParse(); +MultiSegmentParse(); +CustomLimits(); +ChunkedBody(); +ErrorHandling(); +ReuseAndDispose(); + +request.Dispose(); // returns the pooled header/query arrays to the ArrayPool + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Parse a request that's already in one contiguous buffer (the fast path). +// ───────────────────────────────────────────────────────────────────────────── +void ContiguousParse() +{ + Console.WriteLine("== 1. Contiguous parse =="); + + // The request bytes as one block. TryExtractFullHeaderROM takes a ReadOnlyMemory. + ReadOnlyMemory input = Encoding.ASCII.GetBytes( + "GET /api/users?page=1&sort=asc HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Accept: */*\r\n\r\n"); + + request.Clear(); + var limits = ParserLimits.Default; + + // Returns true on a complete, valid header block; false if the buffer is incomplete; + // throws HttpParseException if the bytes are a complete but invalid/malicious request. + if (UltraHardenedParser.TryExtractFullHeaderROM(ref input, request, in limits, out int bytesRead)) + { + // Every field is a zero-copy ReadOnlyMemory slice of `input`. + Console.WriteLine($" method = {Ascii(request.Method)}"); // GET + Console.WriteLine($" path = {Ascii(request.Path)}"); // /api/users (query stripped) + Console.WriteLine($" version = {Ascii(request.Version)}"); // HTTP/1.1 + + // Headers are an ordered list of (name, value) byte-slice pairs. + Console.WriteLine($" headers ({request.Headers.Count}):"); + for (int i = 0; i < request.Headers.Count; i++) + { + var (name, value) = request.Headers[i]; + Console.WriteLine($" {Ascii(name)}: {Ascii(value)}"); + } + + // Query parameters are parsed out of the request target. + Console.WriteLine($" query ({request.QueryParameters.Count}):"); + for (int i = 0; i < request.QueryParameters.Count; i++) + { + var (key, val) = request.QueryParameters[i]; + Console.WriteLine($" {Ascii(key)} = {Ascii(val)}"); + } + + // bytesRead follows glyph11's -1 convention: the body begins at bytesRead + 1. + Console.WriteLine($" header block length = {bytesRead + 1} bytes"); + } + Console.WriteLine(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Parse a fragmented request (a ReadOnlySequence split across buffers) — what +// you get from a PipeReader/socket. The parser walks the segments for you. +// ───────────────────────────────────────────────────────────────────────────── +void MultiSegmentParse() +{ + Console.WriteLine("== 2. Multi-segment parse =="); + + byte[] all = Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + + // Build a 3-segment ReadOnlySequence to simulate fragmented network reads. + ReadOnlySequence buffer = ThreeSegments(all); + + request.Clear(); + var limits = ParserLimits.Default; + + // Same contract as Example 1, but for a (possibly) multi-segment sequence. It takes the + // contiguous fast path automatically when the sequence happens to be single-segment. + if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out int bytesRead)) + Console.WriteLine($" parsed {Ascii(request.Method)} {Ascii(request.Path)} from 3 segments\n"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Tighten the security policy with a `with` expression (ParserLimits is a +// readonly record struct). Exceeding a limit is a rejection (HTTP 431), never +// an overflow. +// ───────────────────────────────────────────────────────────────────────────── +void CustomLimits() +{ + Console.WriteLine("== 3. Custom limits =="); + + var strict = ParserLimits.Default with + { + MaxHeaderCount = 2, // allow at most 2 headers + MaxHeaderValueLength = 1024, + MaxUrlLength = 256, + MaxTotalHeaderBytes = 4 * 1024, + }; + + // This request has 3 headers — more than MaxHeaderCount = 2, so it's rejected. + ReadOnlyMemory tooMany = Encoding.ASCII.GetBytes( + "GET / HTTP/1.1\r\nHost: x\r\nA: 1\r\nB: 2\r\n\r\n"); + + request.Clear(); + try + { + UltraHardenedParser.TryExtractFullHeaderROM(ref tooMany, request, in strict, out _); + Console.WriteLine(" unexpectedly accepted"); + } + catch (HttpParseException ex) + { + // A limit breach maps to HTTP 431; a structural/semantic error maps to 400. + Console.WriteLine($" rejected: {ex.Message}\n"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Decode a chunked (Transfer-Encoding: chunked) body with ChunkedBodyStream. +// ───────────────────────────────────────────────────────────────────────────── +void ChunkedBody() +{ + Console.WriteLine("== 4. Chunked body =="); + + // "Hello" + " World" framed as two chunks, then the terminal 0-length chunk. + byte[] body = Encoding.ASCII.GetBytes("5\r\nHello\r\n6\r\n World\r\n0\r\n\r\n"); + + var decoder = new ChunkedBodyStream(); + var decoded = new ArrayBufferWriter(); + int offset = 0; + + while (true) + { + // consumed = input bytes used (framing + payload) + // dataOffset = where the payload starts, relative to the slice we passed + // dataLength = payload byte count + ChunkResult r = decoder.TryReadChunk( + body.AsSpan(offset), out int consumed, out int dataOffset, out int dataLength); + + if (r == ChunkResult.Chunk) + { + decoded.Write(body.AsSpan(offset + dataOffset, dataLength)); + offset += consumed; + continue; + } + // Completed = terminal 0-chunk seen (body done); NeedMoreData = read more, then retry. + // (Malformed framing surfaces as an HttpParseException from the parse path.) + break; + } + + Console.WriteLine($" decoded body = \"{Encoding.ASCII.GetString(decoded.WrittenSpan)}\"\n"); // Hello World +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. The three outcomes: valid (true), incomplete (false), invalid (throws). +// ───────────────────────────────────────────────────────────────────────────── +void ErrorHandling() +{ + Console.WriteLine("== 5. Error handling =="); + var limits = ParserLimits.Default; + + // (a) Incomplete — the header block isn't terminated yet. Returns false: read more. + ReadOnlyMemory partial = Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: x\r\n"); + request.Clear(); + bool ok = UltraHardenedParser.TryExtractFullHeaderROM(ref partial, request, in limits, out _); + Console.WriteLine($" incomplete request → returned {ok} (need more bytes)"); + + // (b) Invalid — a path-traversal attempt. A complete but malicious request → throws. + ReadOnlyMemory evil = Encoding.ASCII.GetBytes("GET /a/../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n"); + request.Clear(); + try + { + UltraHardenedParser.TryExtractFullHeaderROM(ref evil, request, in limits, out _); + } + catch (HttpParseException ex) + { + Console.WriteLine($" malicious request → threw: {ex.Message}\n"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Reuse across requests, then dispose. Clear() keeps the pooled arrays; Dispose() +// returns them. Not disposing won't leak — it just skips the pooling. +// ───────────────────────────────────────────────────────────────────────────── +void ReuseAndDispose() +{ + Console.WriteLine("== 6. Reuse =="); + var limits = ParserLimits.Default; + var shared = new BinaryRequest(); + + foreach (var path in new[] { "/a", "/b", "/c" }) + { + ReadOnlyMemory req = Encoding.ASCII.GetBytes($"GET {path} HTTP/1.1\r\nHost: x\r\n\r\n"); + shared.Clear(); // reset before each parse; reuses the same pooled storage + if (UltraHardenedParser.TryExtractFullHeaderROM(ref req, shared, in limits, out _)) + Console.WriteLine($" parsed {Ascii(shared.Path)}"); + } + + shared.Dispose(); + Console.WriteLine(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── +static string Ascii(ReadOnlyMemory m) => Encoding.ASCII.GetString(m.Span); + +static ReadOnlySequence ThreeSegments(byte[] data) +{ + int s1 = data.Length / 3, s2 = 2 * data.Length / 3; + var first = new Seg(data.AsMemory(0, s1)); + var last = first.Append(data.AsMemory(s1, s2 - s1)).Append(data.AsMemory(s2)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); +} + +// Minimal ReadOnlySequenceSegment to chain memory blocks into one sequence. +sealed class Seg : ReadOnlySequenceSegment +{ + public Seg(ReadOnlyMemory memory) => Memory = memory; + public Seg Append(ReadOnlyMemory memory) + { + var next = new Seg(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } +} diff --git a/Examples/Glyph11.Native.Example/Glyph11.Native.Example.csproj b/Examples/Glyph11.Native.Example/Glyph11.Native.Example.csproj new file mode 100644 index 0000000..49f77db --- /dev/null +++ b/Examples/Glyph11.Native.Example/Glyph11.Native.Example.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + + + + diff --git a/Examples/Glyph11.Native.Example/Program.cs b/Examples/Glyph11.Native.Example/Program.cs new file mode 100644 index 0000000..e16bcce --- /dev/null +++ b/Examples/Glyph11.Native.Example/Program.cs @@ -0,0 +1,180 @@ +// Glyph11.Native (the C core via P/Invoke) — every option, end to end. +// +// dotnet run --project Examples/Glyph11.Native.Example +// +// The NuGet package bundles the native libglyph11. Running in-repo, point at a built core: +// GLYPH11_NATIVE_PATH=core/build/libglyph11.so dotnet run --project Examples/Glyph11.Native.Example +// (see Examples/README.md). Output is zero-copy: the parser writes (offset,length) spans +// into arrays you provide; you slice the input buffer to read them. + +using System.Buffers; +using System.Text; +using Glyph11.Native; + +Console.WriteLine($"libglyph11 ABI = 0x{Glyph11Parser.AbiVersion():x6}\n"); + +ContiguousParse(); +SequenceParse(); +CustomLimitsAndPooling(); +StatusCodes(); +ChunkedDecode(); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Parse a contiguous buffer. You supply the field storage; nothing is allocated. +// ───────────────────────────────────────────────────────────────────────────── +void ContiguousParse() +{ + Console.WriteLine("== 1. Contiguous parse =="); + + byte[] request = Encoding.ASCII.GetBytes( + "GET /api/users?page=1 HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n"); + + var limits = Glyph11Limits.Default; + + // Size storage to the limits so any request the policy accepts fits. The parser + // bounds-checks every write — a smaller array just lowers your effective limit. + Span headers = stackalloc Glyph11Field[(int)limits.MaxHeaderCount]; + Span query = stackalloc Glyph11Field[(int)limits.MaxQueryParameterCount]; + + int status = Glyph11Parser.Parse(request, headers, query, limits, out Glyph11Result r); + if (status == Glyph11Parser.Ok) + { + // Offsets index into `request` — slice it to read. + string Slice(Glyph11Span s) => Encoding.ASCII.GetString(request, (int)s.Offset, (int)s.Length); + Console.WriteLine($" method = {Slice(r.Method)}"); + Console.WriteLine($" path = {Slice(r.Path)}"); // query stripped + Console.WriteLine($" target = {Slice(r.Target)}"); // full target, as received + Console.WriteLine($" version = {Slice(r.Version)}"); + + for (int i = 0; i < r.HeaderCount; i++) + Console.WriteLine($" header {Slice(headers[i].Name)}: {Slice(headers[i].Value)}"); + for (int i = 0; i < r.QueryCount; i++) + Console.WriteLine($" query {Slice(query[i].Name)} = {Slice(query[i].Value)}"); + + Console.WriteLine($" body begins at byte {r.Consumed}"); + } + Console.WriteLine(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Parse a fragmented ReadOnlySequence. The C core needs one contiguous buffer, +// so multi-segment input is linearized into a scratch buffer YOU provide +// (keeping the zero-allocation contract). `parsed` tells you which buffer the +// offsets index into. +// ───────────────────────────────────────────────────────────────────────────── +void SequenceParse() +{ + Console.WriteLine("== 2. ReadOnlySequence parse =="); + + byte[] all = Encoding.ASCII.GetBytes("GET /x?a=1 HTTP/1.1\r\nHost: h\r\n\r\n"); + ReadOnlySequence seq = ThreeSegments(all); // simulate fragmented reads + + var limits = Glyph11Limits.Default; + Span headers = stackalloc Glyph11Field[(int)limits.MaxHeaderCount]; + Span query = stackalloc Glyph11Field[(int)limits.MaxQueryParameterCount]; + + // scratch only needs to hold a request when the input is fragmented (single-segment + // input is parsed in place). Size it to MaxTotalHeaderBytes. + Span scratch = stackalloc byte[8 * 1024]; + + int status = Glyph11Parser.Parse( + seq, scratch, headers, query, limits, + out Glyph11Result r, + out ReadOnlySpan parsed); // ← the contiguous bytes the offsets index into + + if (status == Glyph11Parser.Ok) + { + // Slice against `parsed` (input's first segment if single, else `scratch`) — NOT `seq`. + Console.WriteLine($" method = {Encoding.ASCII.GetString(parsed.Slice((int)r.Method.Offset, (int)r.Method.Length))}"); + Console.WriteLine($" path = {Encoding.ASCII.GetString(parsed.Slice((int)r.Path.Offset, (int)r.Path.Length))}\n"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. A custom policy, and pooled storage for large limits (instead of stackalloc). +// ───────────────────────────────────────────────────────────────────────────── +void CustomLimitsAndPooling() +{ + Console.WriteLine("== 3. Custom limits + pooled storage =="); + + var limits = Glyph11Limits.Default; // Glyph11Limits is a struct — copy and set fields + limits.MaxHeaderCount = 200; + limits.MaxTotalHeaderBytes = 64 * 1024; + + byte[] request = Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + + // Big limits → rent from ArrayPool rather than blowing the stack. + Glyph11Field[] headers = ArrayPool.Shared.Rent((int)limits.MaxHeaderCount); + Glyph11Field[] query = ArrayPool.Shared.Rent((int)limits.MaxQueryParameterCount); + try + { + int status = Glyph11Parser.Parse(request, headers, query, limits, out var r); + Console.WriteLine($" status={status} headers={r.HeaderCount}\n"); + } + finally + { + ArrayPool.Shared.Return(headers); + ArrayPool.Shared.Return(query); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. The status int: 0 = OK, 1 = incomplete, anything else maps to an HTTP code. +// ───────────────────────────────────────────────────────────────────────────── +void StatusCodes() +{ + Console.WriteLine("== 4. Status codes =="); + var limits = Glyph11Limits.Default; + Span h = stackalloc Glyph11Field[(int)limits.MaxHeaderCount]; + Span q = stackalloc Glyph11Field[(int)limits.MaxQueryParameterCount]; + + // Incomplete: header block not terminated → status 1 (read more, retry). + int incomplete = Glyph11Parser.Parse( + Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: x\r\n"), h, q, limits, out _); + Console.WriteLine($" incomplete → status {incomplete} (Incomplete = {Glyph11Parser.Incomplete})"); + + // Invalid: missing Host → an error status that maps to HTTP 400. + int bad = Glyph11Parser.Parse( + Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\n\r\n"), h, q, limits, out _); + Console.WriteLine($" no-Host → status {bad} → HTTP {Glyph11Parser.HttpCode(bad)}\n"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Decode a chunked body with the streaming native decoder. Feed each read; a +// chunk's payload may span calls and the decoder carries the partial state. +// ───────────────────────────────────────────────────────────────────────────── +void ChunkedDecode() +{ + Console.WriteLine("== 5. Chunked decode =="); + + byte[] chunked = Encoding.ASCII.GetBytes("5\r\nHello\r\n6\r\n World\r\n0\r\n\r\n"); + + Glyph11Chunked.Init(out Glyph11ChunkDecoder decoder); // one decoder per body (zeroed state) + Span output = stackalloc byte[256]; // decoded bytes land here + + Glyph11ChunkResult r = Glyph11Chunked.Decode( + ref decoder, chunked, output, out int inConsumed, out int outWritten); + + Console.WriteLine($" result={r} consumed={inConsumed} wrote={outWritten}"); + Console.WriteLine($" decoded = \"{Encoding.ASCII.GetString(output[..outWritten])}\"\n"); // Hello World +} + +// ───────────────────────────────────────────────────────────────────────────── +static ReadOnlySequence ThreeSegments(byte[] data) +{ + int s1 = data.Length / 3, s2 = 2 * data.Length / 3; + var first = new Seg(data.AsMemory(0, s1)); + var last = first.Append(data.AsMemory(s1, s2 - s1)).Append(data.AsMemory(s2)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); +} + +sealed class Seg : ReadOnlySequenceSegment +{ + public Seg(ReadOnlyMemory memory) => Memory = memory; + public Seg Append(ReadOnlyMemory memory) + { + var next = new Seg(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } +} diff --git a/Examples/Glyph11.Pico.Example/Glyph11.Pico.Example.csproj b/Examples/Glyph11.Pico.Example/Glyph11.Pico.Example.csproj new file mode 100644 index 0000000..0265017 --- /dev/null +++ b/Examples/Glyph11.Pico.Example/Glyph11.Pico.Example.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + + + + diff --git a/Examples/Glyph11.Pico.Example/Program.cs b/Examples/Glyph11.Pico.Example/Program.cs new file mode 100644 index 0000000..227c2b0 --- /dev/null +++ b/Examples/Glyph11.Pico.Example/Program.cs @@ -0,0 +1,139 @@ +// Glyph11.Pico (picohttpparser + managed glue) — every option, end to end. +// +// dotnet run --project Examples/Glyph11.Pico.Example +// +// Fills the SAME BinaryRequest as the managed Glyph11 parser, but only picohttpparser's +// validation — fastest path to a request, no hardening. The NuGet bundles libglyph11pico; +// running in-repo, point at a built lib (see Examples/README.md): +// GLYPH11_PICO_NATIVE_PATH=<...>/libglyph11pico.so dotnet run --project Examples/Glyph11.Pico.Example + +using System.Buffers; +using System.Text; +using Glyph11.Pico; +using Glyph11.Protocol; // BinaryRequest +using Glyph11.Parser; // ChunkedBodyStream (chunked decoding is glyph11's) + +// Same reusable BinaryRequest as the managed parser — allocate once, Clear() per request. +var request = new BinaryRequest(); + +ContiguousParse(); +SequenceParse(); +ChunkedBody(); +ValidationTradeoff(); + +request.Dispose(); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Parse a contiguous buffer into a BinaryRequest (identical shape to Glyph11). +// ───────────────────────────────────────────────────────────────────────────── +void ContiguousParse() +{ + Console.WriteLine("== 1. Contiguous parse =="); + + byte[] input = "GET /api/users?page=1&sort=asc HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n"u8.ToArray(); + + request.Clear(); + + // true → parsed; false → malformed or incomplete. `consumed` follows glyph11's -1 + // convention (the body begins at consumed + 1). + if (PicoParser.TryParse(input, request, out int consumed)) + { + Console.WriteLine($" method = {Ascii(request.Method)}"); + Console.WriteLine($" path = {Ascii(request.Path)}"); // query stripped + Console.WriteLine($" version = {Ascii(request.Version)}"); + + for (int i = 0; i < request.Headers.Count; i++) + { + var (name, value) = request.Headers[i]; + Console.WriteLine($" header {Ascii(name)}: {Ascii(value)}"); + } + for (int i = 0; i < request.QueryParameters.Count; i++) + { + var (key, val) = request.QueryParameters[i]; + Console.WriteLine($" query {Ascii(key)} = {Ascii(val)}"); + } + Console.WriteLine($" body begins at byte {consumed + 1}"); + } + Console.WriteLine(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Parse a fragmented ReadOnlySequence. Single-segment is zero-copy; multi-segment +// is linearized into a fresh array internally — which the BinaryRequest slices keep +// alive, so there's no buffer for you to manage. +// ───────────────────────────────────────────────────────────────────────────── +void SequenceParse() +{ + Console.WriteLine("== 2. ReadOnlySequence parse =="); + + byte[] all = "GET /x?a=1 HTTP/1.1\r\nHost: h\r\n\r\n"u8.ToArray(); + ReadOnlySequence seq = ThreeSegments(all); + + request.Clear(); + if (PicoParser.TryParse(seq, request, out _)) + Console.WriteLine($" parsed {Ascii(request.Method)} {Ascii(request.Path)} from 3 segments\n"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Chunked bodies use glyph11's decoder (this package depends on Glyph11). +// ───────────────────────────────────────────────────────────────────────────── +void ChunkedBody() +{ + Console.WriteLine("== 3. Chunked body (via Glyph11.Parser.ChunkedBodyStream) =="); + + byte[] body = "5\r\nHello\r\n6\r\n World\r\n0\r\n\r\n"u8.ToArray(); + var decoder = new ChunkedBodyStream(); + var decoded = new ArrayBufferWriter(); + int offset = 0; + + while (true) + { + ChunkResult r = decoder.TryReadChunk( + body.AsSpan(offset), out int consumed, out int dataOffset, out int dataLength); + if (r == ChunkResult.Chunk) { decoded.Write(body.AsSpan(offset + dataOffset, dataLength)); offset += consumed; continue; } + break; // Completed (done) or NeedMoreData (read more) + } + Console.WriteLine($" decoded = \"{Encoding.ASCII.GetString(decoded.WrittenSpan)}\"\n"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. The trade-off: Pico does NOT do glyph11's hardening. A path-traversal request +// that the managed/native parser rejects is happily tokenized here — so only use +// Pico when you validate elsewhere or trust the source. +// ───────────────────────────────────────────────────────────────────────────── +void ValidationTradeoff() +{ + Console.WriteLine("== 4. Validation trade-off =="); + + // Glyph11 / Glyph11.Native would throw / reject this (dot-segment traversal). Pico parses it. + byte[] evil = "GET /a/../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n"u8.ToArray(); + + request.Clear(); + if (PicoParser.TryParse(evil, request, out _)) + { + Console.WriteLine($" Pico accepted path = {Ascii(request.Path)}"); + Console.WriteLine(" → validate paths/tokens yourself when using Pico on untrusted input.\n"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +static string Ascii(ReadOnlyMemory m) => Encoding.ASCII.GetString(m.Span); + +static ReadOnlySequence ThreeSegments(byte[] data) +{ + int s1 = data.Length / 3, s2 = 2 * data.Length / 3; + var first = new Seg(data.AsMemory(0, s1)); + var last = first.Append(data.AsMemory(s1, s2 - s1)).Append(data.AsMemory(s2)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); +} + +sealed class Seg : ReadOnlySequenceSegment +{ + public Seg(ReadOnlyMemory memory) => Memory = memory; + public Seg Append(ReadOnlyMemory memory) + { + var next = new Seg(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } +} diff --git a/Examples/README.md b/Examples/README.md new file mode 100644 index 0000000..96ff487 --- /dev/null +++ b/Examples/README.md @@ -0,0 +1,47 @@ +# Examples + +Runnable, fully-commented examples for each Glyph11 NuGet package. Each project mirrors what a +consumer writes (the same `using`s and APIs) — in-repo they use a `ProjectReference`; a real app +uses `dotnet add package `. + +| Project | Package | Native lib needed to run? | +|---|---|---| +| [`Glyph11.Example`](Glyph11.Example) | [`Glyph11`](https://www.nuget.org/packages/Glyph11/) | No — pure managed | +| [`Glyph11.Native.Example`](Glyph11.Native.Example) | [`Glyph11.Native`](https://www.nuget.org/packages/Glyph11.Native/) | Yes — `libglyph11` | +| [`Glyph11.Pico.Example`](Glyph11.Pico.Example) | [`Glyph11.Pico`](https://www.nuget.org/packages/Glyph11.Pico/) | Yes — `libglyph11pico` | + +Each `Program.cs` walks through **every option**: contiguous parse, `ReadOnlySequence` parse, +custom limits, reading fields/headers/query, chunked decoding, status/error handling, and reuse. + +## Run + +The managed example needs nothing extra: + +```sh +dotnet run --project Examples/Glyph11.Example +``` + +The **native** examples need the matching native library on the load path. With the NuGet packages +it's bundled automatically; running in-repo, build the core and point at it with an env var: + +```sh +# Glyph11.Native — build libglyph11 and run +cmake -S core -B core/build -DGLYPH11_BUILD_TESTS=OFF && cmake --build core/build +GLYPH11_NATIVE_PATH="$PWD/core/build/libglyph11.so" \ + dotnet run --project Examples/Glyph11.Native.Example + +# Glyph11.Pico — build libglyph11pico and run +cmake -S bindings/dotnet/Glyph11.Pico/native -B pico-build && cmake --build pico-build +GLYPH11_PICO_NATIVE_PATH="$PWD/pico-build/libglyph11pico.so" \ + dotnet run --project Examples/Glyph11.Pico.Example +``` + +(On Windows/macOS the library is `glyph11.dll` / `libglyph11.dylib`, etc.) + +## Which package? + +- **Glyph11** — hardened, dependency-free, runs anywhere. Returns a `BinaryRequest`. +- **Glyph11.Native** — the same hardening via the C core, native speed, zero allocation. Raw spans. +- **Glyph11.Pico** — fastest to a `BinaryRequest`, picohttpparser-level validation only. + +See the [docs site](https://dotnet-web-stack.github.io/Glyph11/) for the full reference. diff --git a/bench/.gitignore b/bench/.gitignore deleted file mode 100644 index fbca225..0000000 --- a/bench/.gitignore +++ /dev/null @@ -1 +0,0 @@ -results/ diff --git a/bench/README.md b/bench/README.md deleted file mode 100644 index 72b0e55..0000000 --- a/bench/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Cross-language parser benchmark - -Measures `glyph11_parse_request` throughput (ns/op) the **same way across every -runtime** — warmup + timed loop over **identical payloads** -(`bench/gen_payloads.py` writes `small.bin` / `h4k.bin` / `h32k.bin`, read by all -benches): - -Each parser is measured in **contiguous** (single buffer) and **multi-segment** -(3-segment) modes: - -- **C# Ultra** — the standalone `UltraHardenedParser` (ROM + multi-segment). -- **Pure C** — `core/bench/bench.c`, the native core floor. -- **C# (FFI)** — the P/Invoke binding (`bindings/dotnet/Glyph11.Bench -- csv `). -- **Kotlin (FFI)** — the Panama/FFM binding (`bindings/kotlin … bench `). - -The C core takes one contiguous buffer, so the native multi-segment columns -measure *linearize 3 segments into a reused buffer, then parse* — a memcpy, not -the per-call allocation the managed multi-segment path does. - -## Run - -```sh -./bench/run-all.sh # builds all, writes bench/results/{results.md,results.json} -``` - -Requires gcc + CMake, .NET 10, and JDK 21 + Gradle. These are the cross-language -harnesses (pure C, C# managed + FFI, Kotlin FFI); the published docs site uses the -per-package request + chunked tables in the root README. - -## Latest local run (best of 5 trials; .NET 10, JDK 21, x86-64) - -**Contiguous** (single buffer — parsed in place, no linearization): - -| Payload | C# Ultra | Pure C | C# (FFI) | Kotlin (FFI) | -|---------|---------:|--------:|---------:|-------------:| -| ~95 B | 116 ns | 98 ns | 97 ns | 100 ns | -| 4 KB | 730 ns | 503 ns | 502 ns | 513 ns | -| 32 KB | 5269 ns | 3628 ns | 3675 ns | 3696 ns | - -The native (Pure C / FFI / Kotlin) numbers use the AVX2 build shipped for `linux-x64` -(`-march=x86-64-v3`), which inlines the 256-bit scanners — ~1.14× over the portable SSE2 -build on the 32 KB header parse (the win is in the scanners, so it shows most on large, -header-heavy payloads). The managed C# parser is unchanged. - -**Multi-segment** (3 segments → allocate a buffer per request, linearize, parse): - -| Payload | C# Ultra | Pure C | C# (FFI) | Kotlin (FFI) | -|---------|---------:|--------:|---------:|-------------:| -| ~95 B | 255 ns | 102 ns | 120 ns | 173 ns | -| 4 KB | 1337 ns | 570 ns | 612 ns | 769 ns | -| 32 KB | 9008 ns | 4539 ns | 4590 ns | 5325 ns | - -Single-segment parses the contiguous data in place. Multi-segment must first linearize -into a contiguous buffer — and **every** parser allocates that buffer per request here, -so the cost reflects each runtime's allocator: - -- **managed** — `TryExtractFullHeaderValidated` allocates a GC array (`ToArray`) → - **~1.8–2.2×** single-segment. GC pressure, not the copy (the copy is only ~425 ns). -- **pure C / C# FFI** — `malloc` / `NativeMemory` → **~1.1×**. Native allocation is cheap. -- **Kotlin** — a per-request FFM `Arena` → **~1.3×**. The arena costs more than raw `malloc`. - -So the multi-segment cost is the **allocation**, and the C core's advantage is native -memory: a GC'd 32 KB array every request is what makes the managed path ~2×. (A hot path -would reuse one scratch buffer instead — then every parser drops to ~1.0–1.1×.) Numbers -vary run-to-run. - -**Chunked body** (decode a chunked transfer-encoding body — strip the framing, copy the -payload into a reused output buffer): - -| Decoded size | C# managed | Pure C | C# (FFI) | Kotlin (FFI) | -|--------------|-----------:|-------:|---------:|-------------:| -| 256 B | 20 ns | 13 ns | 21 ns | 30 ns | -| 4 KB | 114 ns | 71 ns | 76 ns | 89 ns | -| 32 KB | 806 ns | 625 ns | 740 ns | 749 ns | - -Chunked decode is memcpy-bound — the payload copy dominates, so all four land close, an -order of magnitude under header parsing. Pure C leads (a tight decode-to-output memcpy); -the managed path trails slightly because it loops the span parser chunk-by-chunk and -copies each payload separately, where the native decoders stream the whole body in one -call. The FFI paths add only P/Invoke / FFM call overhead over pure C. diff --git a/bench/aggregate.py b/bench/aggregate.py deleted file mode 100644 index 9cc4f4e..0000000 --- a/bench/aggregate.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""Aggregate cross-language bench CSV (`lang,payload,ns` on stdin) into -results.md + results.json in the output dir (argv[1], default cwd).""" -import datetime -import json -import os -import sys - -LANGS = [ - ("dotnet-managed-rom", "C# Ultra (ROM)"), - ("dotnet-managed-multiseg", "C# Ultra (multi-seg)"), - ("pure-c", "Pure C"), - ("pure-c-multiseg", "Pure C (multi-seg)"), - ("dotnet-ffi", "C# binding (FFI)"), - ("dotnet-ffi-multiseg", "C# binding (multi-seg)"), - ("kotlin-ffi", "Kotlin binding (FFI)"), - ("kotlin-ffi-multiseg", "Kotlin binding (multi-seg)"), - ("dotnet-managed-chunked", "C# managed (chunked)"), - ("pure-c-chunked", "Pure C (chunked)"), - ("dotnet-ffi-chunked", "C# binding (chunked)"), - ("kotlin-ffi-chunked", "Kotlin binding (chunked)"), -] -PAYLOADS = [("small", "~95 B"), ("4k", "4 KB"), ("32k", "32 KB")] - - -def main() -> None: - out = sys.argv[1] if len(sys.argv) > 1 else "." - os.makedirs(out, exist_ok=True) - - data: dict[str, dict[str, float]] = {} - for line in sys.stdin: - parts = line.strip().split(",") - if len(parts) != 3: - continue - lang, payload, ns = parts - try: - data.setdefault(payload, {})[lang] = float(ns) - except ValueError: - continue - - header = "| Payload | " + " | ".join(label for _, label in LANGS) + " |" - md = [header, "|" + "---|" * (len(LANGS) + 1)] - rows_json = [] - for pkey, plabel in PAYLOADS: - row = data.get(pkey, {}) - cells = [(f"{row[k]:.0f} ns" if k in row else "—") for k, _ in LANGS] - md.append(f"| {plabel} | " + " | ".join(cells) + " |") - rows_json.append({"payload": pkey, "label": plabel, **{k: row.get(k) for k, _ in LANGS}}) - - md_text = "\n".join(md) + "\n" - with open(os.path.join(out, "results.md"), "w") as f: - f.write(md_text) - generated = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - with open(os.path.join(out, "results.json"), "w") as f: - json.dump({ - "unit": "ns/op", - "generated": generated, - "langs": [{"key": k, "label": v} for k, v in LANGS], - "rows": rows_json, - }, f, indent=2) - sys.stdout.write(md_text) - - -if __name__ == "__main__": - main() diff --git a/bench/gen_payloads.py b/bench/gen_payloads.py deleted file mode 100644 index 1214726..0000000 --- a/bench/gen_payloads.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the shared benchmark payloads so every language benches identical bytes. -Writes small.bin / h4k.bin / h32k.bin to the given directory (default: cwd).""" -import os -import sys - - -def build_header(target: int) -> bytes: - s = "GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\nHost: localhost\r\n" - i = 0 - while len(s) < target - 4: - name = f"X-Header-{i}" - i += 1 - remaining = target - len(s) - len(name) - 4 - vlen = min(max(remaining, 1), 200) - s += f"{name}: " + ("A" * vlen) + "\r\n" - s += "\r\n" - return s.encode("ascii") - - -def build_chunked(decoded_size: int, chunk_size: int = 512) -> bytes: - """A chunked transfer-encoding body whose decoded length is `decoded_size`.""" - body = bytes(i % 251 for i in range(decoded_size)) - out = bytearray() - for i in range(0, decoded_size, chunk_size): - c = body[i:i + chunk_size] - out += f"{len(c):x}".encode() + b"\r\n" + c + b"\r\n" - out += b"0\r\n\r\n" - return bytes(out) - - -SMALL = ( - "GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n" - "Host: localhost\r\nContent-Length: 100\r\nServer: Glyph11\r\n\r\n" -).encode("ascii") - - -def main() -> None: - out = sys.argv[1] if len(sys.argv) > 1 else "." - os.makedirs(out, exist_ok=True) - payloads = { - "small.bin": SMALL, "h4k.bin": build_header(4096), "h32k.bin": build_header(32768), - "chunked_small.bin": build_chunked(256), - "chunked_4k.bin": build_chunked(4096), - "chunked_32k.bin": build_chunked(32768), - } - for name, data in payloads.items(): - with open(os.path.join(out, name), "wb") as f: - f.write(data) - print("payloads -> " + out + ": " + ", ".join(f"{n}={len(d)}B" for n, d in payloads.items())) - - -if __name__ == "__main__": - main() diff --git a/bench/run-all.sh b/bench/run-all.sh deleted file mode 100755 index fefa2f0..0000000 --- a/bench/run-all.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Run the cross-language parser benchmark (pure C, C# managed + FFI, Kotlin FFI) -# on identical payloads and aggregate into bench/results/{results.md,results.json}. -set -euo pipefail -root="$(cd "$(dirname "$0")/.." && pwd)" -out="${1:-$root/bench/results}" -pay="$(mktemp -d)" -csv="$(mktemp)" -trap 'rm -rf "$pay" "$csv"' EXIT - -python3 "$root/bench/gen_payloads.py" "$pay" >&2 - -# --- build the C core (release) --- -# AVX2 to match the shipped linux-x64 package binary (GLYPH11_X86_AVX2) — the bench -# host must be AVX2-capable (2013+; CI ubuntu runners are). Drop the flag to bench the -# portable SSE2 build instead. -cmake -S "$root/core" -B "$root/core/build-rel" \ - -DGLYPH11_SANITIZE=OFF -DGLYPH11_BUILD_TESTS=OFF -DGLYPH11_BUILD_BENCH=ON \ - -DGLYPH11_X86_AVX2=ON -DCMAKE_BUILD_TYPE=Release >/dev/null -cmake --build "$root/core/build-rel" >/dev/null -so="$root/core/build-rel/libglyph11.so" - -# --- pure C --- -"$root/core/build-rel/glyph11_bench" "$pay" >>"$csv" - -# --- C# (managed + FFI) --- -GLYPH11_NATIVE_PATH="$so" dotnet run -c Release --project "$root/bindings/dotnet/Glyph11.Bench" -- csv "$pay" \ - 2>/dev/null | grep '^dotnet-' >>"$csv" - -# --- Kotlin (FFI) --- -( cd "$root/bindings/kotlin" && GLYPH11_LIB="$so" ./gradlew -q run --args="bench $pay" --console=plain 2>/dev/null ) \ - | grep '^kotlin-' >>"$csv" - -echo "" >&2 -python3 "$root/bench/aggregate.py" "$out" <"$csv" -echo "wrote $out/results.{md,json}" >&2 diff --git a/bindings/dotnet/Glyph11.Bench/Glyph11.Bench.csproj b/bindings/dotnet/Glyph11.Bench/Glyph11.Bench.csproj deleted file mode 100644 index 05c1f64..0000000 --- a/bindings/dotnet/Glyph11.Bench/Glyph11.Bench.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - true - enable - enable - true - - - - - - - - - - - - - diff --git a/bindings/dotnet/Glyph11.Bench/Program.cs b/bindings/dotnet/Glyph11.Bench/Program.cs deleted file mode 100644 index da2d9d8..0000000 --- a/bindings/dotnet/Glyph11.Bench/Program.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Buffers; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Engines; -using BenchmarkDotNet.Jobs; -using BenchmarkDotNet.Running; -using Glyph11.Native; -using Glyph11.Parser; -using Glyph11.Parser.UltraHardened; -using Glyph11.Protocol; -using Glyph11.Utils; - -// `csv ` emits CSV (dotnet-managed / dotnet-ffi) for the cross-language -// aggregator, using the shared payload files. Default: the rich BenchmarkDotNet run. -if (args.Length >= 1 && args[0] == "csv") -{ - CsvBench.Run(args.Length > 1 ? args[1] : "."); - return; -} - -BenchmarkRunner.Run(); - -[MemoryDiagnoser] -[SimpleJob(RunStrategy.Throughput, warmupCount: 5, iterationCount: 8)] -public class ParserComparison -{ - // Larger limits so the 4K/32K payloads (many headers) parse on both sides. - private static readonly ParserLimits ManagedLimits = - ParserLimits.Default with { MaxTotalHeaderBytes = 64 * 1024, MaxHeaderCount = 200 }; - - private static readonly Glyph11Limits NativeLimits = new() - { - StructSize = 32, MaxHeaderCount = 200, MaxHeaderNameLength = 256, - MaxHeaderValueLength = 8192, MaxUrlLength = 8192, MaxQueryParameterCount = 128, - MaxMethodLength = 16, MaxTotalHeaderBytes = 64 * 1024, - }; - - private readonly byte[] _small = BuildSmall(); - private readonly byte[] _4k = BuildHeader(4096); - private readonly byte[] _32k = BuildHeader(32768); - - private readonly BinaryRequest _req = new(); - private readonly Glyph11Field[] _h = new Glyph11Field[256]; - private readonly Glyph11Field[] _q = new Glyph11Field[256]; - - // ---- managed (reference) ---- - private bool Managed(byte[] data) - { - _req.Clear(); - var rom = (ReadOnlyMemory)data; - return UltraHardenedParser.TryExtractFullHeaderROM(ref rom, _req, in ManagedLimits, out _); - } - - // ---- native via FFI ---- - private int Native(byte[] data) - => Glyph11Parser.Parse(data, _h, _q, NativeLimits, out _); - - [Benchmark(Baseline = true)] public bool Managed_Small() => Managed(_small); - [Benchmark] public int Native_Small() => Native(_small); - - [Benchmark] public bool Managed_4K() => Managed(_4k); - [Benchmark] public int Native_4K() => Native(_4k); - - [Benchmark] public bool Managed_32K() => Managed(_32k); - [Benchmark] public int Native_32K() => Native(_32k); - - // ---- inputs (Host-bearing so UltraHardened accepts) ---- - private static byte[] BuildSmall() => Encoding.ASCII.GetBytes( - "GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n" + - "Host: localhost\r\nContent-Length: 100\r\nServer: Glyph11\r\n\r\n"); - - private static byte[] BuildHeader(int targetBytes) - { - var sb = new StringBuilder(targetBytes + 128); - sb.Append("GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\nHost: localhost\r\n"); - int index = 0; - while (sb.Length < targetBytes - 4) - { - string name = $"X-Header-{index++}"; - int remaining = targetBytes - sb.Length - name.Length - 4; - int valueLen = Math.Min(Math.Max(remaining, 1), 200); - sb.Append(name).Append(": ").Append('A', valueLen).Append("\r\n"); - } - sb.Append("\r\n"); - return Encoding.ASCII.GetBytes(sb.ToString()); - } -} - -// Consistent manual-timing harness (warmup + timed loop) matching the pure-C and -// Kotlin benches, so the four series are directly comparable in one table. -internal static class CsvBench -{ - private static readonly ParserLimits ManagedLimits = - ParserLimits.Default with { MaxTotalHeaderBytes = 64 * 1024, MaxHeaderCount = 200 }; - - private static readonly Glyph11Limits NativeLimits = new() - { - StructSize = 32, MaxHeaderCount = 200, MaxHeaderNameLength = 256, - MaxHeaderValueLength = 8192, MaxUrlLength = 8192, MaxQueryParameterCount = 128, - MaxMethodLength = 16, MaxTotalHeaderBytes = 64 * 1024, - }; - - public static void Run(string dir) - { - var req = new BinaryRequest(); - var h = new Glyph11Field[256]; - var q = new Glyph11Field[256]; - (string name, string file, long iters)[] cases = - { - ("small", "small.bin", 2_000_000), - ("4k", "h4k.bin", 500_000), - ("32k", "h32k.bin", 100_000), - }; - foreach (var (name, file, iters) in cases) - { - var data = File.ReadAllBytes(Path.Combine(dir, file)); - var rom = (ReadOnlyMemory)data; - var seq = ThreeSegments(data); - - // managed — ROM (single contiguous buffer) - double mRom = Best(iters, () => { req.Clear(); var r = rom; UltraHardenedParser.TryExtractFullHeaderROM(ref r, req, in ManagedLimits, out _); }); - Console.WriteLine($"dotnet-managed-rom,{name},{mRom:F1}"); - - // managed — multi-segment: the library's real path. Single-segment parses the - // contiguous data in place (no buffer); multi-segment must linearize, and - // TryExtractFullHeaderValidated does it by allocating a fresh array (input.ToArray()) - // every request — that GC allocation, not the copy, is the multi-seg cost. - double mSeg = Best(iters, () => { req.Clear(); var s = seq; UltraHardenedParser.TryExtractFullHeaderValidated(ref s, req, in ManagedLimits, out _); }); - Console.WriteLine($"dotnet-managed-multiseg,{name},{mSeg:F1}"); - - // native binding (FFI) — contiguous - double ffi = Best(iters, () => Glyph11Parser.Parse(data, h, q, NativeLimits, out _)); - Console.WriteLine($"dotnet-ffi,{name},{ffi:F1}"); - - // native binding (FFI) — multi-segment: a real binding linearizes into a fresh NATIVE - // buffer per request (NativeMemory, no GC), parses, frees — the C core takes one slab - // and the caller owns the memory, so no GC pressure unlike the managed ToArray. - double ffiSeg = Best(iters, () => FfiMultiSeg(seq, data.Length, h, q, in NativeLimits)); - Console.WriteLine($"dotnet-ffi-multiseg,{name},{ffiSeg:F1}"); - } - - // ---- chunked body: decode the whole body into a reused output buffer ---- - (string name, string file, long iters)[] chunked = - { - ("small", "chunked_small.bin", 1_000_000), - ("4k", "chunked_4k.bin", 300_000), - ("32k", "chunked_32k.bin", 50_000), - }; - var chOut = new byte[64 * 1024]; - foreach (var (cname, cfile, citers) in chunked) - { - var body = File.ReadAllBytes(Path.Combine(dir, cfile)); - Console.WriteLine($"dotnet-managed-chunked,{cname},{Best(citers, () => DecodeManagedChunked(body, chOut)):F1}"); - Console.WriteLine($"dotnet-ffi-chunked,{cname},{Best(citers, () => DecodeFfiChunked(body, chOut)):F1}"); - } - req.Dispose(); - } - - /// Managed: loop the span-based parser, copying each chunk payload into . - private static void DecodeManagedChunked(byte[] body, byte[] output) - { - var stream = new ChunkedBodyStream(); - int inOff = 0, outOff = 0; - while (true) - { - var r = stream.TryReadChunk(body.AsSpan(inOff), out int consumed, out int dataOff, out int dataLen); - if (r != ChunkResult.Chunk) break; - body.AsSpan(inOff + dataOff, dataLen).CopyTo(output.AsSpan(outOff)); - outOff += dataLen; - inOff += consumed; - } - } - - /// FFI: one streaming decode straight into . - private static void DecodeFfiChunked(byte[] body, byte[] output) - { - Glyph11Chunked.Init(out var dec); - Glyph11Chunked.Decode(ref dec, body, output, out _, out _); - } - - private static unsafe void FfiMultiSeg(ReadOnlySequence seq, int len, Glyph11Field[] h, Glyph11Field[] q, in Glyph11Limits lim) - { - byte* p = (byte*)NativeMemory.Alloc((nuint)len); - try - { - var span = new Span(p, len); - seq.CopyTo(span); - Glyph11Parser.Parse(span, h, q, lim, out _); - } - finally { NativeMemory.Free(p); } - } - - // best of N timed trials (after warmup) — filters scheduling / turbo interference - private static double Best(long iters, Action body) - { - for (long i = 0; i < iters / 10 + 1; i++) body(); - double best = double.MaxValue; - for (int t = 0; t < 5; t++) - { - var sw = Stopwatch.StartNew(); - for (long i = 0; i < iters; i++) body(); - sw.Stop(); - var ns = sw.Elapsed.TotalNanoseconds / iters; - if (ns < best) best = ns; - } - return best; - } - - private static ReadOnlySequence ThreeSegments(byte[] d) - { - int s1 = d.Length / 3, s2 = 2 * d.Length / 3; - var first = new BufferSegment(d.AsMemory(0, s1)); - var last = first.Append(d.AsMemory(s1, s2 - s1)).Append(d.AsMemory(s2)); - return new ReadOnlySequence(first, 0, last, last.Memory.Length); - } -} diff --git a/bindings/dotnet/Glyph11.Bench/README.md b/bindings/dotnet/Glyph11.Bench/README.md deleted file mode 100644 index 27c6629..0000000 --- a/bindings/dotnet/Glyph11.Bench/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Managed vs native (FFI) benchmark - -Compares the standalone C# `UltraHardenedParser` against the same C core called -via P/Invoke (`[SuppressGCTransition]`). Same inputs, both zero-allocation. The -C# library is referenced read-only and is **not** modified. - -## Run - -```sh -# build the release .so first -cmake -S core -B core/build-rel -DGLYPH11_SANITIZE=OFF -DGLYPH11_BUILD_TESTS=OFF -cmake --build core/build-rel - -GLYPH11_NATIVE_PATH="$PWD/core/build-rel/libglyph11.so" \ - dotnet run -c Release --project bindings/dotnet/Glyph11.Bench -``` - -## Results (gcc 13 -O3, .NET 10, x86-64) - -| Payload | Managed | Native FFI (scalar) | Native FFI (SSE2) | vs managed | -|---------|--------:|--------------------:|------------------:|:-----------| -| ~80 B | 128 ns | 94 ns | **91 ns** | **0.71×** (faster) | -| 4 KB | 690 ns | 1,244 ns | **540 ns** | **0.78×** (faster) | -| 32 KB | 4.88 µs | 10.0 µs | **4.00 µs** | **0.82×** (faster) | - -**Read:** - -- P/Invoke overhead is *not* the bottleneck — with `[SuppressGCTransition]`, - native wins even on tiny requests (the common case). -- With **scalar** character-class validation the C core lost on large payloads - (1.8–2.1×): the managed parser validates with hardware SIMD - (`SearchValues` / `IndexOfAnyExcept`). -- Adding **SSE2** scanning to the C core (header-value + request-target - validation) flipped it: the native parser is now **faster than the managed - parser at every size**, ~2.3–2.5× faster than its own scalar version on - 4 KB / 32 KB. Parity with C# is preserved (1M-input differential, ASan-clean). diff --git a/bindings/kotlin/src/main/kotlin/io/glyph11/Glyph11.kt b/bindings/kotlin/src/main/kotlin/io/glyph11/Glyph11.kt index 7dfd8c1..4ca844a 100644 --- a/bindings/kotlin/src/main/kotlin/io/glyph11/Glyph11.kt +++ b/bindings/kotlin/src/main/kotlin/io/glyph11/Glyph11.kt @@ -83,20 +83,6 @@ object Glyph11 { FunctionDescriptor.of(ValueLayout.JAVA_INT), ) - private val chunkDecodeHandle = handle( - "glyph11_chunk_decode", - FunctionDescriptor.of( - ValueLayout.JAVA_INT, - ValueLayout.ADDRESS, // glyph11_chunk_decoder* dec - ValueLayout.ADDRESS, // const uint8_t* in - ValueLayout.JAVA_LONG, // size_t in_len - ValueLayout.ADDRESS, // uint8_t* out - ValueLayout.JAVA_LONG, // size_t out_cap - ValueLayout.ADDRESS, // size_t* in_consumed - ValueLayout.ADDRESS, // size_t* out_written - ), - ) - /** Packed ABI version of the loaded native library. */ val abiVersion: Int get() = abiHandle.invoke() as Int @@ -146,102 +132,4 @@ object Glyph11 { } } - /** - * Benchmark helper: parse [input] [iters] times, returning ns/op. Contiguous - * ([multiSeg] = false) reuses one native buffer; multi-segment allocates a fresh - * native buffer (a per-call [Arena]) each request, mirroring real linearization. - */ - fun benchParse(input: ByteArray, iters: Long, multiSeg: Boolean = false): Double { - Arena.ofConfined().use { arena -> - val buf = arena.allocate(maxOf(input.size, 1).toLong()) - if (!multiSeg) MemorySegment.copy(input, 0, buf, ValueLayout.JAVA_BYTE, 0L, input.size) - val s1 = input.size / 3 - val s2 = 2 * input.size / 3 - val headers = arena.allocate(SIZEOF_FIELD * CAPACITY) - val query = arena.allocate(SIZEOF_FIELD * CAPACITY) - val req = arena.allocate(SIZEOF_REQUEST) - val consumed = arena.allocate(ValueLayout.JAVA_LONG) - val len = input.size.toLong() - - // Limits matching the other benches (max 200 headers, 64 KiB total) so the - // 32 KB payload (153 headers) parses fully instead of being rejected early - // with TOO_MANY_HEADERS under the default 100-header cap. - val lim = arena.allocate(32) - lim.set(ValueLayout.JAVA_INT, 0L, 32) // struct_size - lim.set(ValueLayout.JAVA_INT, 4L, 200) // max_header_count - lim.set(ValueLayout.JAVA_INT, 8L, 256) // max_header_name_length - lim.set(ValueLayout.JAVA_INT, 12L, 8192) // max_header_value_length - lim.set(ValueLayout.JAVA_INT, 16L, 8192) // max_url_length - lim.set(ValueLayout.JAVA_INT, 20L, 128) // max_query_param_count - lim.set(ValueLayout.JAVA_INT, 24L, 16) // max_method_length - lim.set(ValueLayout.JAVA_INT, 28L, 64 * 1024) // max_total_header_bytes - - fun parseInto(b: MemorySegment) { - req.set(ValueLayout.ADDRESS, OFF_HEADERS, headers) - req.set(ValueLayout.JAVA_INT, OFF_HEADER_CAP, CAPACITY) - req.set(ValueLayout.ADDRESS, OFF_QUERY, query) - req.set(ValueLayout.JAVA_INT, OFF_QUERY_CAP, CAPACITY) - parseHandle.invoke(b, len, lim, req, consumed) - } - fun once() { - if (multiSeg) { - // a real binding linearizes a fresh native buffer per request (no reuse) - Arena.ofConfined().use { tmp -> - val b = tmp.allocate(len) - MemorySegment.copy(input, 0, b, ValueLayout.JAVA_BYTE, 0L, s1) - MemorySegment.copy(input, s1, b, ValueLayout.JAVA_BYTE, s1.toLong(), s2 - s1) - MemorySegment.copy(input, s2, b, ValueLayout.JAVA_BYTE, s2.toLong(), input.size - s2) - parseInto(b) - } - } else { - parseInto(buf) - } - } - - var w = 0L - while (w < iters / 10 + 1) { once(); w++ } // warmup (also lets the JIT compile) - var best = Double.MAX_VALUE // best of N trials filters scheduling / turbo interference - for (trial in 0 until 5) { - val t0 = System.nanoTime() - var i = 0L - while (i < iters) { once(); i++ } - val t1 = System.nanoTime() - val ns = (t1 - t0).toDouble() / iters - if (ns < best) best = ns - } - return best - } - } - - /** Benchmark helper: decode the chunked body [input] [iters] times, returning ns/op. */ - fun benchChunked(input: ByteArray, iters: Long): Double { - Arena.ofConfined().use { arena -> - val inBuf = arena.allocate(maxOf(input.size, 1).toLong()) - MemorySegment.copy(input, 0, inBuf, ValueLayout.JAVA_BYTE, 0L, input.size) - val outBuf = arena.allocate(64L * 1024) - val dec = arena.allocate(32) - val ic = arena.allocate(ValueLayout.JAVA_LONG) - val ow = arena.allocate(ValueLayout.JAVA_LONG) - val len = input.size.toLong() - val outCap = 64L * 1024 - - fun once() { - dec.fill(0.toByte()) // reset decoder (zero == start state) - chunkDecodeHandle.invoke(dec, inBuf, len, outBuf, outCap, ic, ow) - } - - var w = 0L - while (w < iters / 10 + 1) { once(); w++ } // warmup - var best = Double.MAX_VALUE - for (trial in 0 until 5) { - val t0 = System.nanoTime() - var i = 0L - while (i < iters) { once(); i++ } - val t1 = System.nanoTime() - val ns = (t1 - t0).toDouble() / iters - if (ns < best) best = ns - } - return best - } - } } diff --git a/bindings/kotlin/src/main/kotlin/io/glyph11/Main.kt b/bindings/kotlin/src/main/kotlin/io/glyph11/Main.kt index 92da213..54736c6 100644 --- a/bindings/kotlin/src/main/kotlin/io/glyph11/Main.kt +++ b/bindings/kotlin/src/main/kotlin/io/glyph11/Main.kt @@ -1,40 +1,11 @@ package io.glyph11 -import java.io.File import kotlin.system.exitProcess -fun main(args: Array) { - if (args.isNotEmpty() && args[0] == "bench") { - bench(if (args.size > 1) args[1] else ".") - return - } +fun main() { smoke() } -/** Emit `kotlin-ffi,,` for the cross-language aggregator. */ -private fun bench(dir: String) { - val cases = listOf( - Triple("small", "small.bin", 2_000_000L), - Triple("4k", "h4k.bin", 500_000L), - Triple("32k", "h32k.bin", 100_000L), - ) - for ((name, file, iters) in cases) { - val data = File(dir, file).readBytes() - println("kotlin-ffi,%s,%.1f".format(name, Glyph11.benchParse(data, iters))) - println("kotlin-ffi-multiseg,%s,%.1f".format(name, Glyph11.benchParse(data, iters, multiSeg = true))) - } - - val chunked = listOf( - Triple("small", "chunked_small.bin", 1_000_000L), - Triple("4k", "chunked_4k.bin", 300_000L), - Triple("32k", "chunked_32k.bin", 50_000L), - ) - for ((name, file, iters) in chunked) { - val data = File(dir, file).readBytes() - println("kotlin-ffi-chunked,%s,%.1f".format(name, Glyph11.benchChunked(data, iters))) - } -} - /** Smoke test: parse a few requests via the native core and verify the results. */ private fun smoke() { println("glyph11 abi 0x%06x".format(Glyph11.abiVersion)) diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 8c6a3c2..0d5b482 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -74,10 +74,3 @@ if(GLYPH11_BUILD_TESTS) target_compile_options(fuzz_smoke PRIVATE ${GLYPH11_WARNINGS}) add_test(NAME glyph11_fuzz_smoke COMMAND fuzz_smoke) endif() - -option(GLYPH11_BUILD_BENCH "Build the pure-C micro-benchmark" OFF) -if(GLYPH11_BUILD_BENCH) - add_executable(glyph11_bench bench/bench.c) - target_link_libraries(glyph11_bench PRIVATE glyph11) - target_compile_options(glyph11_bench PRIVATE ${GLYPH11_WARNINGS}) -endif() diff --git a/core/bench/bench.c b/core/bench/bench.c deleted file mode 100644 index 9577da2..0000000 --- a/core/bench/bench.c +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Pure-C micro-benchmark for the Glyph11 parser — no FFI, the native floor. - * Reads the shared payload files (small.bin / h4k.bin / h32k.bin) and prints - * pure-c,, - * one line per payload, for the cross-language aggregator. - */ -#include "glyph11.h" -#include -#include -#include -#include - -static unsigned char* read_file(const char* path, size_t* out_len) -{ - FILE* f = fopen(path, "rb"); - if (!f) { fprintf(stderr, "bench: cannot open %s\n", path); exit(2); } - fseek(f, 0, SEEK_END); - long n = ftell(f); - fseek(f, 0, SEEK_SET); - unsigned char* b = (unsigned char*)malloc((size_t)n); - if (!b || fread(b, 1, (size_t)n, f) != (size_t)n) { fprintf(stderr, "bench: read %s\n", path); exit(2); } - fclose(f); - *out_len = (size_t)n; - return b; -} - -static double bench(const unsigned char* buf, size_t len, const glyph11_limits* lim, long iters) -{ - static glyph11_field h[256], q[256]; - glyph11_request r; - for (long i = 0; i < iters / 10 + 1; i++) { /* warmup */ - r.headers = h; r.header_cap = 256; r.query = q; r.query_cap = 256; - glyph11_parse_request(buf, len, lim, &r, NULL); - } - double best = 1e30; /* best of N trials filters scheduling/turbo interference */ - for (int trial = 0; trial < 5; trial++) { - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); - for (long i = 0; i < iters; i++) { - r.headers = h; r.header_cap = 256; r.query = q; r.query_cap = 256; - glyph11_parse_request(buf, len, lim, &r, NULL); - } - clock_gettime(CLOCK_MONOTONIC, &t1); - double ns = ((double)(t1.tv_sec - t0.tv_sec) * 1e9 + (double)(t1.tv_nsec - t0.tv_nsec)) / (double)iters; - if (ns < best) best = ns; - } - return best; -} - -/* Multi-segment: allocate a fresh buffer per request (malloc), linearize 3 segments, parse, free. */ -static double bench_ms(const unsigned char* buf, size_t len, const glyph11_limits* lim, long iters) -{ - static glyph11_field h[256], q[256]; - glyph11_request r; - size_t s1 = len / 3, s2 = 2 * len / 3; - for (long i = 0; i < iters / 10 + 1; i++) { /* warmup */ - unsigned char* dst = (unsigned char*)malloc(len); - memcpy(dst, buf, s1); memcpy(dst + s1, buf + s1, s2 - s1); memcpy(dst + s2, buf + s2, len - s2); - r.headers = h; r.header_cap = 256; r.query = q; r.query_cap = 256; - glyph11_parse_request(dst, len, lim, &r, NULL); - free(dst); - } - double best = 1e30; - for (int trial = 0; trial < 5; trial++) { - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); - for (long i = 0; i < iters; i++) { - unsigned char* dst = (unsigned char*)malloc(len); - memcpy(dst, buf, s1); memcpy(dst + s1, buf + s1, s2 - s1); memcpy(dst + s2, buf + s2, len - s2); - r.headers = h; r.header_cap = 256; r.query = q; r.query_cap = 256; - glyph11_parse_request(dst, len, lim, &r, NULL); - free(dst); - } - clock_gettime(CLOCK_MONOTONIC, &t1); - double ns = ((double)(t1.tv_sec - t0.tv_sec) * 1e9 + (double)(t1.tv_nsec - t0.tv_nsec)) / (double)iters; - if (ns < best) best = ns; - } - return best; -} - -/* Chunked transfer-encoding: decode the whole body into a reused output buffer. */ -static double bench_chunked(const unsigned char* buf, size_t len, long iters) -{ - static unsigned char out[64 * 1024]; - glyph11_chunk_decoder d; - size_t ic, ow; - for (long i = 0; i < iters / 10 + 1; i++) { /* warmup */ - glyph11_chunk_decoder_init(&d); - glyph11_chunk_decode(&d, buf, len, out, sizeof out, &ic, &ow); - } - double best = 1e30; - for (int trial = 0; trial < 5; trial++) { - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); - for (long i = 0; i < iters; i++) { - glyph11_chunk_decoder_init(&d); - glyph11_chunk_decode(&d, buf, len, out, sizeof out, &ic, &ow); - } - clock_gettime(CLOCK_MONOTONIC, &t1); - double ns = ((double)(t1.tv_sec - t0.tv_sec) * 1e9 + (double)(t1.tv_nsec - t0.tv_nsec)) / (double)iters; - if (ns < best) best = ns; - } - return best; -} - -int main(int argc, char** argv) -{ - const char* dir = argc > 1 ? argv[1] : "."; - glyph11_limits lim; - glyph11_limits_default(&lim); - lim.max_header_count = 200; - lim.max_total_header_bytes = 64 * 1024; - - struct { const char* name; const char* file; long iters; } cases[] = { - { "small", "small.bin", 2000000 }, - { "4k", "h4k.bin", 500000 }, - { "32k", "h32k.bin", 100000 }, - }; - char path[1024]; - for (int i = 0; i < 3; i++) { - size_t len; - snprintf(path, sizeof path, "%s/%s", dir, cases[i].file); - unsigned char* b = read_file(path, &len); - printf("pure-c,%s,%.1f\n", cases[i].name, bench(b, len, &lim, cases[i].iters)); - printf("pure-c-multiseg,%s,%.1f\n", cases[i].name, bench_ms(b, len, &lim, cases[i].iters)); - free(b); - } - - struct { const char* name; const char* file; long iters; } chunked[] = { - { "small", "chunked_small.bin", 1000000 }, - { "4k", "chunked_4k.bin", 300000 }, - { "32k", "chunked_32k.bin", 50000 }, - }; - for (int i = 0; i < 3; i++) { - size_t len; - snprintf(path, sizeof path, "%s/%s", dir, chunked[i].file); - unsigned char* b = read_file(path, &len); - printf("pure-c-chunked,%s,%.1f\n", chunked[i].name, bench_chunked(b, len, chunked[i].iters)); - free(b); - } - return 0; -} diff --git a/src/Examples/GenHTTP/Assembly.cs b/src/Examples/GenHTTP/Assembly.cs deleted file mode 100644 index d7b7f8c..0000000 --- a/src/Examples/GenHTTP/Assembly.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("Tests")] -[assembly: InternalsVisibleTo("Benchmarks")] diff --git a/src/Examples/GenHTTP/ClientHandler.cs b/src/Examples/GenHTTP/ClientHandler.cs deleted file mode 100644 index 1bf4106..0000000 --- a/src/Examples/GenHTTP/ClientHandler.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace GenHTTP; - -public class ClientHandler -{ - -} \ No newline at end of file diff --git a/src/Examples/GenHTTP/Context/ClientContext.cs b/src/Examples/GenHTTP/Context/ClientContext.cs deleted file mode 100644 index 0577300..0000000 --- a/src/Examples/GenHTTP/Context/ClientContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -using GenHTTP.Types; - -namespace GenHTTP.Context; - -public class ClientContext -{ - public Request Request { get; set; } = null!; - - internal void Clear() - { - Request.Reset(); - } -} diff --git a/src/Examples/GenHTTP/Context/ClientContextPolicy.cs b/src/Examples/GenHTTP/Context/ClientContextPolicy.cs deleted file mode 100644 index 9456bf9..0000000 --- a/src/Examples/GenHTTP/Context/ClientContextPolicy.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.ObjectPool; - -namespace GenHTTP.Context; - -public class ClientContextPolicy : PooledObjectPolicy -{ - - public override ClientContext Create() => new(); - - public override bool Return(ClientContext obj) - { - obj.Clear(); - return true; - } - -} \ No newline at end of file diff --git a/src/Examples/GenHTTP/GenHTTP.csproj b/src/Examples/GenHTTP/GenHTTP.csproj deleted file mode 100644 index b78143a..0000000 --- a/src/Examples/GenHTTP/GenHTTP.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net8.0;net9.0;net10.0 - true - false - GenHTTP - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Examples/GenHTTP/Parser/RequestParser.cs b/src/Examples/GenHTTP/Parser/RequestParser.cs deleted file mode 100644 index ddc5937..0000000 --- a/src/Examples/GenHTTP/Parser/RequestParser.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Buffers; -using GenHTTP.Types; -using Glyph11.Parser; -using Glyph11.Parser.UltraHardened; - -namespace GenHTTP.Parser; - -public static class RequestParser -{ - private static readonly ParserLimits Limits = ParserLimits.Default; - - public static bool TryParse(ReadOnlySequence buffer, Request into, out int bytesRead) - => TryParse(buffer, into, in Limits, out bytesRead); - - public static bool TryParse(ReadOnlySequence buffer, Request into, in ParserLimits limits, out int bytesRead) - { - var raw = into.Source; - - // UltraHardenedParser enforces all structural and semantic checks during - // parsing and throws HttpParseException on any violation. - if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, raw, in limits, out bytesRead)) - { - into.Apply(); - return true; - } - - return false; - } - -} diff --git a/src/Examples/GenHTTP/Protocol/IRequest.cs b/src/Examples/GenHTTP/Protocol/IRequest.cs deleted file mode 100644 index 224cceb..0000000 --- a/src/Examples/GenHTTP/Protocol/IRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using GenHTTP.Protocol.Raw; - -namespace GenHTTP.Protocol; - -public interface IRequest -{ - - IRawRequest Raw { get; } - - RequestMethod Method { get; } - -} diff --git a/src/Examples/GenHTTP/Protocol/IResponse.cs b/src/Examples/GenHTTP/Protocol/IResponse.cs deleted file mode 100644 index d9c5f28..0000000 --- a/src/Examples/GenHTTP/Protocol/IResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace GenHTTP.Protocol; - -public interface IResponse -{ - -} diff --git a/src/Examples/GenHTTP/Protocol/Raw/IRawKeyValueList.cs b/src/Examples/GenHTTP/Protocol/Raw/IRawKeyValueList.cs deleted file mode 100644 index cba7632..0000000 --- a/src/Examples/GenHTTP/Protocol/Raw/IRawKeyValueList.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GenHTTP.Protocol.Raw; - -public interface IRawKeyValueList -{ - - int Count { get; } - - KeyValuePair, ReadOnlyMemory> this[int index] { get; } - -} diff --git a/src/Examples/GenHTTP/Protocol/Raw/IRawRequest.cs b/src/Examples/GenHTTP/Protocol/Raw/IRawRequest.cs deleted file mode 100644 index 880fac0..0000000 --- a/src/Examples/GenHTTP/Protocol/Raw/IRawRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace GenHTTP.Protocol.Raw; - -public interface IRawRequest -{ - - ReadOnlyMemory Method { get; } - - ReadOnlyMemory Path { get; } - - IRawRequestTarget Target { get; } - - IRawKeyValueList Query { get; } - - ReadOnlyMemory Version { get; } - - IRawKeyValueList Headers { get; } - -} diff --git a/src/Examples/GenHTTP/Protocol/Raw/IRawRequestTarget.cs b/src/Examples/GenHTTP/Protocol/Raw/IRawRequestTarget.cs deleted file mode 100644 index a1cdacd..0000000 --- a/src/Examples/GenHTTP/Protocol/Raw/IRawRequestTarget.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GenHTTP.Protocol.Raw; - -public interface IRawRequestTarget -{ - - ReadOnlyMemory? Current { get; } - - void Advance(int segments = 1); - -} diff --git a/src/Examples/GenHTTP/Protocol/RequestMethod.cs b/src/Examples/GenHTTP/Protocol/RequestMethod.cs deleted file mode 100644 index f1b4fb2..0000000 --- a/src/Examples/GenHTTP/Protocol/RequestMethod.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace GenHTTP.Protocol; - -public enum RequestMethod -{ - Get, - Head, - Post, - Put, - Delete, - Connect, - Options, - Trace, - Patch, - - // if it cannot be parsed into one of the ones above - Other - -} diff --git a/src/Examples/GenHTTP/Types/RawKeyValueList.cs b/src/Examples/GenHTTP/Types/RawKeyValueList.cs deleted file mode 100644 index 0f802bb..0000000 --- a/src/Examples/GenHTTP/Types/RawKeyValueList.cs +++ /dev/null @@ -1,13 +0,0 @@ -using GenHTTP.Protocol.Raw; -using Glyph11.Protocol; - -namespace GenHTTP.Types; - -public sealed class RawKeyValueList(KeyValueList source) : IRawKeyValueList -{ - - public int Count => source.Count; - - public KeyValuePair, ReadOnlyMemory> this[int index] => source[index]; - -} diff --git a/src/Examples/GenHTTP/Types/RawRequest.cs b/src/Examples/GenHTTP/Types/RawRequest.cs deleted file mode 100644 index 7281b93..0000000 --- a/src/Examples/GenHTTP/Types/RawRequest.cs +++ /dev/null @@ -1,52 +0,0 @@ -using GenHTTP.Protocol.Raw; -using Glyph11.Protocol; - -namespace GenHTTP.Types; - -public sealed class RawRequest : IRawRequest -{ - private readonly BinaryRequest _source; - - private readonly RawKeyValueList _headers; - - private readonly RawKeyValueList _query; - - private readonly RawRequestTarget _target; - - internal BinaryRequest Source => _source; - - public ReadOnlyMemory Method => _source.Method; - - public ReadOnlyMemory Path => _source.Path; - - public IRawRequestTarget Target => _target; - - public ReadOnlyMemory Version => _source.Version; - - public IRawKeyValueList Headers => _headers; - - public IRawKeyValueList Query => _query; - - public ReadOnlyMemory Body { get; set; } - - public RawRequest() - { - _source = new(); - - _headers = new(_source.Headers); - _query = new(_source.QueryParameters); - - _target = new(); - } - - public void Reset() - { - _source.Clear(); - } - - public void Apply() - { - _target.Apply(Path); - } - -} diff --git a/src/Examples/GenHTTP/Types/RawRequestTarget.cs b/src/Examples/GenHTTP/Types/RawRequestTarget.cs deleted file mode 100644 index 30fb27b..0000000 --- a/src/Examples/GenHTTP/Types/RawRequestTarget.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Runtime.CompilerServices; -using GenHTTP.Protocol.Raw; - -namespace GenHTTP.Types; - -public sealed class RawRequestTarget : IRawRequestTarget -{ - private ReadOnlyMemory _path = ReadOnlyMemory.Empty; - - private int _offset; - - public ReadOnlyMemory? Current { get; private set; } - - public void Apply(ReadOnlyMemory path) - { - _path = path; - - _offset = 0; - Current = null; - - MoveNext(); - } - - public void Advance(int segments = 1) - { - while (segments-- > 0 && Current != null) - { - MoveNext(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void MoveNext() - { - var span = _path.Span; - var length = span.Length; - - if (length < 2) - { - Current = null; - return; - } - - while (_offset < length && span[_offset] == (byte)'/') - { - _offset++; - } - - if (_offset >= length) - { - Current = null; - return; - } - - var start = _offset; - - var idx = span[_offset..].IndexOf((byte)'/'); - - if (idx < 0) - { - _offset = length; - Current = _path.Slice(start, length - start); - } - else - { - _offset += idx; - Current = _path.Slice(start, idx); - } - } - -} diff --git a/src/Examples/GenHTTP/Types/Request.cs b/src/Examples/GenHTTP/Types/Request.cs deleted file mode 100644 index 410a629..0000000 --- a/src/Examples/GenHTTP/Types/Request.cs +++ /dev/null @@ -1,57 +0,0 @@ -using GenHTTP.Protocol; -using GenHTTP.Protocol.Raw; -using GenHTTP.Utils; -using Glyph11.Protocol; - -namespace GenHTTP.Types; - -public sealed class Request : IRequest -{ - private readonly RawRequest _raw = new(); - - private RequestMethod? _method; - - public IRawRequest Raw => _raw; - - internal BinaryRequest Source => _raw.Source; - - public RequestMethod Method - { - get - { - if (_method == null) - { - var m = _raw.Method.Span; - - _method = m.Length switch - { - 3 when AsciiComparer.EqualsIgnoreCase(m, "GET"u8) => RequestMethod.Get, - 4 when AsciiComparer.EqualsIgnoreCase(m, "POST"u8) => RequestMethod.Post, - 3 when AsciiComparer.EqualsIgnoreCase(m, "PUT"u8) => RequestMethod.Put, - 6 when AsciiComparer.EqualsIgnoreCase(m, "DELETE"u8) => RequestMethod.Delete, - 4 when AsciiComparer.EqualsIgnoreCase(m, "HEAD"u8) => RequestMethod.Head, - 7 when AsciiComparer.EqualsIgnoreCase(m, "OPTIONS"u8) => RequestMethod.Options, - 5 when AsciiComparer.EqualsIgnoreCase(m, "PATCH"u8) => RequestMethod.Patch, - 5 when AsciiComparer.EqualsIgnoreCase(m, "TRACE"u8) => RequestMethod.Trace, - 7 when AsciiComparer.EqualsIgnoreCase(m, "CONNECT"u8) => RequestMethod.Connect, - _ => RequestMethod.Other - }; - } - - return _method.Value; - } - } - - public void Reset() - { - _raw.Reset(); - - _method = null; - } - - public void Apply() - { - _raw.Apply(); - } - -} diff --git a/src/Examples/GenHTTP/Utils/AsciiComparer.cs b/src/Examples/GenHTTP/Utils/AsciiComparer.cs deleted file mode 100644 index dc8f7a0..0000000 --- a/src/Examples/GenHTTP/Utils/AsciiComparer.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace GenHTTP.Utils; - -public static class AsciiComparer -{ - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool EqualsIgnoreCase(ReadOnlyMemory a, ReadOnlyMemory b) - => EqualsIgnoreCase(a.Span, b.Span); - - public static bool EqualsIgnoreCase(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 ((uint)(x - 'A') <= 25) x = (byte)(x + 32); - if ((uint)(y - 'A') <= 25) y = (byte)(y + 32); - - if (x != y) - { - return false; - } - } - - return true; - } - -} diff --git a/src/Examples/ServerTest/Program.cs b/src/Examples/ServerTest/Program.cs deleted file mode 100644 index 7267216..0000000 --- a/src/Examples/ServerTest/Program.cs +++ /dev/null @@ -1,250 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; -using System.Net; -using System.Net.Sockets; -using System.Text; -using Glyph11; -using Glyph11.Parser; -using Glyph11.Parser.UltraHardened; -using Glyph11.Protocol; -using Glyph11.Validation; - -var port = args.Length > 0 && int.TryParse(args[0], out var p) ? p : 8080; - -var listener = new TcpListener(IPAddress.Any, port); -listener.Start(); - -Console.WriteLine($"GlyphServer listening on http://localhost:{port}"); - -using var cts = new CancellationTokenSource(); -Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; - -try -{ - while (!cts.Token.IsCancellationRequested) - { - var client = await listener.AcceptTcpClientAsync(cts.Token); - _ = HandleClientAsync(client, cts.Token); - } -} -catch (OperationCanceledException) { } - -listener.Stop(); -Console.WriteLine("Server stopped."); - -static async Task HandleClientAsync(TcpClient client, CancellationToken ct) -{ - using (client) - await using (var stream = client.GetStream()) - { - var limits = ParserLimits.Default; - var reader = PipeReader.Create(stream); - using var request = new BinaryRequest(); - - try - { - while (!ct.IsCancellationRequested) - { - // ── Phase 1: parse header ────────────────────────── - // Loop until we have a complete header. Do NOT advance - // the pipe yet — request holds ReadOnlyMemory slices - // into the pipe buffer. - ReadOnlySequence headerBuffer; - int headerByteCount; - while (true) - { - request.Clear(); - var result = await reader.ReadAsync(ct); - var buffer = result.Buffer; - - if (result.IsCompleted && buffer.IsEmpty) - { - await reader.CompleteAsync(); - return; - } - - var sequence = buffer; - try - { - // TODO FOR SINGLE SEQUENCE THERE ARE NO ALLOCATIONS, FOR MULTI SEGMENT THERE ARE, THAT INTERFERES THE BEHAVIOR - // TODO MEANING WE CANT ADVANCE FOR SINGLE SEGMENT CASE - - if (UltraHardenedParser.TryExtractFullHeaderValidated(ref sequence, request, in limits, out var bytesRead)) - { - Console.WriteLine(Encoding.UTF8.GetString(sequence)); - - headerByteCount = bytesRead + 1; - headerBuffer = buffer; - break; - } - - if (buffer.Length > limits.MaxTotalHeaderBytes) - { - reader.AdvanceTo(buffer.End); - await stream.WriteAsync(MakeErrorResponse(431, "Request Header Fields Too Large"), ct); - await reader.CompleteAsync(); - return; - } - - reader.AdvanceTo(buffer.Start, buffer.End); - - if (result.IsCompleted) - { - await reader.CompleteAsync(); - return; - } - } - catch (HttpParseException ex) - { - var code = ex.StatusCode; - var reason = code switch - { - 431 => "Request Header Fields Too Large", - _ => "Bad Request" - }; - reader.AdvanceTo(buffer.End); - await stream.WriteAsync(MakeErrorResponse(code, reason), ct); - await reader.CompleteAsync(); - return; - } - } - - // ── Phase 2: validation ──────────────────────────── - // No work needed — UltraHardenedParser enforced all structural and - // semantic checks during Phase 1 parsing (it throws on any violation). - - // ── Phase 3: extract values & detect framing ─────── - // Copy what we need out of the pipe buffer, then release it. - var method = Encoding.ASCII.GetString(request.Method.Span); - var path = Encoding.ASCII.GetString(request.Path.Span); - var framing = BodyFramingDetector.DetectBodyFraming(request); - - // Now safe to advance past the header bytes. - reader.AdvanceTo(headerBuffer.GetPosition(headerByteCount)); - - // ── Phase 4: consume body ────────────────────────── - switch (framing.Framing) - { - case BodyFraming.ContentLength: - { - long remaining = framing.ContentLength; - while (remaining > 0) - { - var result = await reader.ReadAsync(ct); - var buffer = result.Buffer; - long available = Math.Min(buffer.Length, remaining); - remaining -= available; - reader.AdvanceTo(buffer.GetPosition(available)); - - if (result.IsCompleted && remaining > 0) - { - await reader.CompleteAsync(); - return; - } - } - break; - } - - case BodyFraming.Chunked: - { - var chunked = new ChunkedBodyStream(); - while (true) - { - var result = await reader.ReadAsync(ct); - var buffer = result.Buffer; - - ReadOnlySpan span; - byte[]? linearized = null; - if (buffer.IsSingleSegment) - { - span = buffer.FirstSpan; - } - else - { - linearized = new byte[buffer.Length]; - buffer.CopyTo(linearized); - span = linearized; - } - - bool done = false; - int totalConsumed = 0; - while (true) - { - var cr = chunked.TryReadChunk(span[totalConsumed..], out var consumed, out _, out _); - totalConsumed += consumed; - - if (cr == ChunkResult.Completed) - { - done = true; - break; - } - if (cr == ChunkResult.NeedMoreData) - break; - // ChunkResult.Chunk — loop to consume next chunk - } - - reader.AdvanceTo(buffer.GetPosition(totalConsumed)); - - if (done) - break; - - if (result.IsCompleted) - { - await reader.CompleteAsync(); - return; - } - } - break; - } - - case BodyFraming.None: - default: - break; - } - - // ── Phase 5: send response ───────────────────────── - var responseBytes = BuildResponse(method, path); - await stream.WriteAsync(responseBytes, ct); - } - } - catch (OperationCanceledException) { } - catch (IOException) { } - catch (HttpParseException ex) - { - var code = ex.StatusCode; - var reason = code switch - { - 431 => "Request Header Fields Too Large", - _ => "Bad Request" - }; - try { await stream.WriteAsync(MakeErrorResponse(code, reason), ct); } catch { } - } - finally - { - await reader.CompleteAsync(); - } - } -} - -static byte[] BuildResponse(string method, string path) -{ - var body = $"Hello from GlyphServer\r\nMethod: {method}\r\nPath: {path}\r\n"; - return MakeResponse(200, "OK", body); -} - -static byte[] MakeResponse(int status, string reason, string body) -{ - var bodyBytes = Encoding.UTF8.GetBytes(body); - var header = $"HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain\r\nContent-Length: {bodyBytes.Length}\r\nConnection: keep-alive\r\n\r\n"; - var headerBytes = Encoding.ASCII.GetBytes(header); - - var result = new byte[headerBytes.Length + bodyBytes.Length]; - Buffer.BlockCopy(headerBytes, 0, result, 0, headerBytes.Length); - Buffer.BlockCopy(bodyBytes, 0, result, headerBytes.Length, bodyBytes.Length); - return result; -} - -static byte[] MakeErrorResponse(int status, string reason) -{ - return MakeResponse(status, reason, $"{status} {reason}\r\n"); -} diff --git a/src/Examples/ServerTest/ServerTest.csproj b/src/Examples/ServerTest/ServerTest.csproj deleted file mode 100644 index 52c5f75..0000000 --- a/src/Examples/ServerTest/ServerTest.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - - - diff --git a/src/Glyph11.sln b/src/Glyph11.sln index c2f330d..0004c8f 100644 --- a/src/Glyph11.sln +++ b/src/Glyph11.sln @@ -4,13 +4,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Glyph11", "Glyph11\Glyph11. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "..\tests\Tests\Tests.csproj", "{8DD133D0-C052-4CF9-8A09-6E948E68311B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP", "Examples\GenHTTP\GenHTTP.csproj", "{D6936307-4A8C-4636-ADA2-4761D101970E}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "..\Benchmarks\Benchmarks.csproj", "{0DF18FBF-F275-4CA3-ACA6-E9D60E21DE6D}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{B1832E06-A86C-42E2-BB12-CE83BDE6D77A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Glyph11.Example", "..\Examples\Glyph11.Example\Glyph11.Example.csproj", "{9BB41491-CA01-4D1B-93CC-BEF6C449179A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Glyph11.Native.Example", "..\Examples\Glyph11.Native.Example\Glyph11.Native.Example.csproj", "{3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServerTest", "Examples\ServerTest\ServerTest.csproj", "{CE2C01E2-C498-45EE-9EA9-67F43787D00D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Glyph11.Pico.Example", "..\Examples\Glyph11.Pico.Example\Glyph11.Pico.Example.csproj", "{CE3462D0-F32A-4ED0-91E1-6B03980FB656}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -46,18 +46,6 @@ Global {8DD133D0-C052-4CF9-8A09-6E948E68311B}.Release|x64.Build.0 = Release|Any CPU {8DD133D0-C052-4CF9-8A09-6E948E68311B}.Release|x86.ActiveCfg = Release|Any CPU {8DD133D0-C052-4CF9-8A09-6E948E68311B}.Release|x86.Build.0 = Release|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Debug|x64.ActiveCfg = Debug|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Debug|x64.Build.0 = Debug|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Debug|x86.ActiveCfg = Debug|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Debug|x86.Build.0 = Debug|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Release|Any CPU.Build.0 = Release|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Release|x64.ActiveCfg = Release|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Release|x64.Build.0 = Release|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Release|x86.ActiveCfg = Release|Any CPU - {D6936307-4A8C-4636-ADA2-4761D101970E}.Release|x86.Build.0 = Release|Any CPU {0DF18FBF-F275-4CA3-ACA6-E9D60E21DE6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0DF18FBF-F275-4CA3-ACA6-E9D60E21DE6D}.Debug|Any CPU.Build.0 = Debug|Any CPU {0DF18FBF-F275-4CA3-ACA6-E9D60E21DE6D}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -70,24 +58,44 @@ Global {0DF18FBF-F275-4CA3-ACA6-E9D60E21DE6D}.Release|x64.Build.0 = Release|Any CPU {0DF18FBF-F275-4CA3-ACA6-E9D60E21DE6D}.Release|x86.ActiveCfg = Release|Any CPU {0DF18FBF-F275-4CA3-ACA6-E9D60E21DE6D}.Release|x86.Build.0 = Release|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Debug|x64.ActiveCfg = Debug|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Debug|x64.Build.0 = Debug|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Debug|x86.ActiveCfg = Debug|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Debug|x86.Build.0 = Debug|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Release|Any CPU.Build.0 = Release|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Release|x64.ActiveCfg = Release|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Release|x64.Build.0 = Release|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Release|x86.ActiveCfg = Release|Any CPU - {CE2C01E2-C498-45EE-9EA9-67F43787D00D}.Release|x86.Build.0 = Release|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Debug|x64.Build.0 = Debug|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Debug|x86.ActiveCfg = Debug|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Debug|x86.Build.0 = Debug|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Release|Any CPU.Build.0 = Release|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Release|x64.ActiveCfg = Release|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Release|x64.Build.0 = Release|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Release|x86.ActiveCfg = Release|Any CPU + {9BB41491-CA01-4D1B-93CC-BEF6C449179A}.Release|x86.Build.0 = Release|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Debug|x64.ActiveCfg = Debug|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Debug|x64.Build.0 = Debug|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Debug|x86.ActiveCfg = Debug|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Debug|x86.Build.0 = Debug|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Release|Any CPU.Build.0 = Release|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Release|x64.ActiveCfg = Release|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Release|x64.Build.0 = Release|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Release|x86.ActiveCfg = Release|Any CPU + {3E9D42DA-8C26-4C4A-99F6-E275121BDBA9}.Release|x86.Build.0 = Release|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Debug|x64.Build.0 = Debug|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Debug|x86.Build.0 = Debug|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Release|Any CPU.Build.0 = Release|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Release|x64.ActiveCfg = Release|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Release|x64.Build.0 = Release|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Release|x86.ActiveCfg = Release|Any CPU + {CE3462D0-F32A-4ED0-91E1-6B03980FB656}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {D6936307-4A8C-4636-ADA2-4761D101970E} = {B1832E06-A86C-42E2-BB12-CE83BDE6D77A} - {CE2C01E2-C498-45EE-9EA9-67F43787D00D} = {B1832E06-A86C-42E2-BB12-CE83BDE6D77A} - EndGlobalSection EndGlobal diff --git a/tests/Tests/FlexibleParser.TryExtractFullHeader.cs b/tests/Tests/FlexibleParser.TryExtractFullHeader.cs index f8ba054..ed097cc 100644 --- a/tests/Tests/FlexibleParser.TryExtractFullHeader.cs +++ b/tests/Tests/FlexibleParser.TryExtractFullHeader.cs @@ -1,7 +1,5 @@ using System.Buffers; using System.Text; -using GenHTTP.Protocol; -using GenHTTP.Types; using Glyph11.Parser.FlexibleParser; using Glyph11.Protocol; @@ -17,14 +15,14 @@ public void ParseSingleSegmentRequest() var request = "GET /route?p1=1&p2=2&p3=3&p4=4 HTTP/1.1\r\n" + "Content-Length: 100\r\n" + - "Server: GenHTTP\r\n" + + "Server: Glyph11\r\n" + "\r\n"; ReadOnlyMemory rom = Encoding.ASCII.GetBytes(request); - var data = new Request(); + var data = new BinaryRequest(); - var parsed = FlexibleParser.TryExtractFullHeaderReadOnlyMemory(ref rom, data.Source, out var position); + var parsed = FlexibleParser.TryExtractFullHeaderReadOnlyMemory(ref rom, data, out var position); Assert.True(parsed); AssertRequestParsedCorrectly(data); @@ -38,9 +36,9 @@ public void ParseMultiSegmentRequest() { ReadOnlySequence segmented = CreateMultiSegment(); - var data = new Request(); + var data = new BinaryRequest(); - var parsed = FlexibleParser.TryExtractFullHeader(ref segmented, data.Source, out var position); + var parsed = FlexibleParser.TryExtractFullHeader(ref segmented, data, out var position); Assert.True(parsed); AssertRequestParsedCorrectly(data); @@ -48,17 +46,14 @@ public void ParseMultiSegmentRequest() Assert.Equal((int)segmented.Length - 1, position); } - private static void AssertRequestParsedCorrectly(Request data) + private static void AssertRequestParsedCorrectly(BinaryRequest data) { - // Method (enum + raw bytes) - Assert.Equal(RequestMethod.Get, data.Method); - AssertAscii.Equal("GET", data.Source.Method); - - // Route (path only) - AssertAscii.Equal(ExpectedPath, data.Source.Path); + // Method + path (path only, query stripped) + AssertAscii.Equal("GET", data.Method); + AssertAscii.Equal(ExpectedPath, data.Path); // Query params - var qp = data.Source.QueryParameters; + var qp = data.QueryParameters; Assert.Equal(4, qp.Count); AssertKeyValue(qp, "p1", "1"); @@ -67,11 +62,11 @@ private static void AssertRequestParsedCorrectly(Request data) AssertKeyValue(qp, "p4", "4"); // Headers - var headers = data.Source.Headers; + var headers = data.Headers; Assert.Equal(2, headers.Count); AssertKeyValue(headers, "Content-Length", "100"); - AssertKeyValue(headers, "Server", "GenHTTP"); + AssertKeyValue(headers, "Server", "Glyph11"); } private static void AssertKeyValue(KeyValueList list, string expectedKey, string expectedValue) @@ -106,7 +101,7 @@ private static ReadOnlySequence CreateMultiSegment() { var seg1 = "GET /route?p1=1&p2=2&p3=3&p4=4 HT"u8.ToArray(); var seg2 = "TP/1.1\r\nContent-Length: 100\r\nServer: "u8.ToArray(); - var seg3 = "GenHTTP\r\n\r\n"u8.ToArray(); + var seg3 = "Glyph11\r\n\r\n"u8.ToArray(); var first = new Glyph11.Utils.BufferSegment(seg1); var last = first.Append(seg2).Append(seg3); diff --git a/tests/Tests/Tests.csproj b/tests/Tests/Tests.csproj index b65ad4b..9268718 100644 --- a/tests/Tests/Tests.csproj +++ b/tests/Tests/Tests.csproj @@ -20,7 +20,6 @@ -