From bafe8f182ce7290ff645af496d4629b56e6b07a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Wed, 29 Apr 2026 22:34:01 +0200 Subject: [PATCH 1/9] feat(native): add SSE / streaming response support NativeTlsClient gains four new methods (sync + async) backed by the requestStream / readStream / readStreamAll / cancelStream exports of the native tls-client library: RequestStream returns once headers are available, body stays open ReadStream polls the next chunk; supports timeout heartbeats ReadStreamAll drains the rest as a normal Response, for the case where Content-Type turns out NOT to be a stream CancelStream idempotent teardown The existing Request() path is unchanged. Older native libraries that don't expose the streaming entry points still load; IsStreamingSupported reports false and streaming methods throw a clear error on first use. * TlsClient.Core: new request/response models (ReadStreamRequest, ReadStreamAllRequest, CancelStreamRequest, StreamStartResponse, StreamChunkResponse with EOF / Timeout / Error states and a GetChunkBytes helper) * docs/streaming.md (linked from readme.md): usage guide covering SSE, Content-Type-based branching, timeout semantics, single- thread tunneling, tuning knobs and troubleshooting * csproj versions bumped to 1.1.0-stream.1; GeneratePackageOnBuild removed in favour of explicit dotnet pack * prepare.js takes an optional PACKAGE_VERSION env var so generated platform packages can be stamped independently of TLS_CLIENT_VERSION * .gitignore excludes generated platform package dirs, build/temp DLL staging, local-nuget pack output, and ad-hoc /runtimes/ staging Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 19 ++ build/native-builder/prepare.js | 5 + docs/streaming.md | 211 ++++++++++++++++++ readme.md | 11 + .../Models/Requests/CancelStreamRequest.cs | 10 + .../Models/Requests/ReadStreamAllRequest.cs | 10 + .../Models/Requests/ReadStreamRequest.cs | 20 ++ .../Models/Responses/StreamChunkResponse.cs | 48 ++++ .../Models/Responses/StreamStartResponse.cs | 16 ++ src/TlsClient.Core/TlsClient.Core.csproj | 4 + src/TlsClient.Native/NativeTlsClient.cs | 158 +++++++++++++ src/TlsClient.Native/TlsClient.Native.csproj | 2 +- .../Wrappers/TlsClientWrapper.cs | 99 ++++++++ src/native/template/{title}.csproj | 2 +- tls-client-version.txt | 2 +- 15 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 docs/streaming.md create mode 100644 src/TlsClient.Core/Models/Requests/CancelStreamRequest.cs create mode 100644 src/TlsClient.Core/Models/Requests/ReadStreamAllRequest.cs create mode 100644 src/TlsClient.Core/Models/Requests/ReadStreamRequest.cs create mode 100644 src/TlsClient.Core/Models/Responses/StreamChunkResponse.cs create mode 100644 src/TlsClient.Core/Models/Responses/StreamStartResponse.cs diff --git a/.gitignore b/.gitignore index b555be3..b07ff25 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,22 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /src/TlsClient.Core/runtimes/win-x64/native/tls-client-latest.dll + +# TlsClient.NET — generated artifacts that must never be committed: +# src/native/TlsClient.Native.{os}-{arch}/ — platform package directories +# rendered from src/native/template/ +# by build/native-builder/prepare.js +# build/temp/ — staging directory for native DLLs +# downloaded by the prepare-libraries +# pipeline before they are placed into +# the rendered platform packages +# local-nuget/ — output of local `dotnet pack` runs +# used to consume the wrapper from +# another solution as a local feed +# /runtimes/ — ad-hoc DLL staging at the repo root +# (e.g. for NativeLoader.GetLibraryPath() +# default lookup during local debugging) +/src/native/TlsClient.Native.*/ +/build/temp/ +/local-nuget/ +/runtimes/ diff --git a/build/native-builder/prepare.js b/build/native-builder/prepare.js index 188b3e7..e4ff85f 100644 --- a/build/native-builder/prepare.js +++ b/build/native-builder/prepare.js @@ -5,6 +5,10 @@ const fsPromises = fs.promises; // Define paths based on environment variables const basePath = process.env.GITHUB_WORKSPACE; const tlsVersion = process.env.TLS_CLIENT_VERSION?.replace('v', '') || ''; +// PACKAGE_VERSION is the NuGet package version stamped into the platform-specific +// project file. Defaults to TLS_CLIENT_VERSION when not set so existing CI runs +// (which only set TLS_CLIENT_VERSION) keep working unchanged. +const packageVersion = process.env.PACKAGE_VERSION || tlsVersion; const tlsClientRegex = new RegExp(`tls-client-(.*)-(.*)-${tlsVersion}\\.(.*)`); // Set up paths @@ -111,6 +115,7 @@ async function processLibraries() { os: lib.os, arch: lib.arch, version: tlsVersion, + packageVersion: packageVersion, ext: lib.ext, runtimeIdentifier: lib.runtimeIdentifier, }; diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..6dfe7c2 --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,211 @@ +# 🔀 Streaming Responses (SSE / chunked) + +`TlsClient.Native` can consume long-lived streaming responses — Server-Sent +Events (`text/event-stream`), NDJSON firehoses, or any chunked HTTP/1.1 / HTTP/2 +body — without buffering the entire body in memory first. + +This is **native-only**. `TlsClient.Api` doesn't expose it because the +upstream `tls-client` HTTP service doesn't forward chunked bodies in a +streaming fashion. + +--- + +## 🧱 The streaming API + +A streaming response is consumed through four short, synchronous calls on +`NativeTlsClient`. Each maps 1:1 to a native entry point in the underlying +`tls-client` library: + +| `NativeTlsClient` method | Native export | What it does | +| ------------------------------------------------- | --------------- | --------------------------------------------------------------------------- | +| `RequestStream(Request)` | `requestStream` | Issues the request and returns once **headers** are available; body stays open and is owned by the native side. | +| `ReadStream(Guid streamId, int timeoutMs)` | `readStream` | Returns the next chunk (base64-encoded bytes), or one of `EOF` / `Timeout` / `Error`. | +| `ReadStreamAll(Guid streamId)` | `readStreamAll` | Drains the rest of the body in one call. Returns the same `Response` shape as the non-streaming `Request()`. | +| `CancelStream(Guid streamId)` | `cancelStream` | Closes the underlying connection and releases native resources. Idempotent — safe to call after EOF, error, or on an unknown id. | + +`async` variants (`RequestStreamAsync`, `ReadStreamAsync`, `ReadStreamAllAsync`, +`CancelStreamAsync`) follow the same contract. + +The static `NativeTlsClient.IsStreamingSupported` reports whether the loaded +native library exposes the four entry points. Older `tls-client` builds may +not, in which case the streaming methods throw `EntryPointNotFoundException` +on first use: + +```csharp +if (!NativeTlsClient.IsStreamingSupported) + throw new NotSupportedException("Loaded native library does not expose the streaming entry points."); +``` + +--- + +## 🚀 Quick Start + +### Server-Sent Events + +```csharp +using TlsClient.Native; +using TlsClient.Core.Models.Entities; +using TlsClient.Core.Models.Requests; +using System.Text; + +NativeTlsClient.Initialize("{PATH_TO_NATIVE_LIBRARY}"); + +using var client = new NativeTlsClient(new TlsClientOptions( + TlsClientIdentifier.Chrome133, + "Mozilla/5.0 ... Chrome/133") +{ + Timeout = TimeSpan.Zero, // ⚠️ streaming requires no client-wide timeout +}); + +var start = client.RequestStream(new Request +{ + RequestUrl = "https://example.com/sse", + RequestMethod = HttpMethod.Get, + Headers = new() { ["Accept"] = "text/event-stream" }, +}); + +try +{ + var sb = new StringBuilder(); + while (true) + { + var chunk = client.ReadStream(start.StreamId, timeoutMs: 1000); + + if (!string.IsNullOrEmpty(chunk.Error)) + throw new IOException($"stream error: {chunk.Error}"); + + if (chunk.Timeout) + { + // No data for 1s. Check your CancellationToken here, then continue. + ct.ThrowIfCancellationRequested(); + continue; + } + + sb.Append(Encoding.UTF8.GetString(chunk.GetChunkBytes())); + + // Pull complete SSE events (`\n\n`-separated) out of the buffer: + int sep; + while ((sep = sb.ToString().IndexOf("\n\n", StringComparison.Ordinal)) >= 0) + { + string ev = sb.ToString(0, sep); + sb.Remove(0, sep + 2); + ProcessEvent(ev); + } + + if (chunk.EOF) break; + } +} +finally +{ + client.CancelStream(start.StreamId); // idempotent +} +``` + +### "I don't know upfront if it's streaming" + +When the same code path may receive either an SSE response *or* a regular JSON +body, branch on `Content-Type` from the headers `RequestStream` returns: + +```csharp +var start = client.RequestStream(req); + +string ct = start.Headers?.GetValueOrDefault("Content-Type")?.FirstOrDefault() ?? ""; +if (ct.Contains("text/event-stream") || ct.Contains("application/x-ndjson")) +{ + // Streaming — loop ReadStream as in the SSE example above. +} +else +{ + // Not streaming — drain the body in one extra FFI call. + Response full = client.ReadStreamAll(start.StreamId); + Console.WriteLine(full.Body); +} +``` + +The non-streaming branch costs **one extra FFI call** vs. plain `Request()` — +negligible compared to the network round-trip and avoids the "predict ahead of +time" problem. + +--- + +## 🔁 The `timeoutMs` parameter on `ReadStream` + +| Value | Behaviour | +| --------- | ------------------------------------------------------------------------------------------ | +| `< 0` | Block until the next chunk, EOF, or error. | +| `= 0` | Non-blocking poll — returns `Timeout=true` immediately if no chunk is buffered. | +| `> 0` | Block up to `timeoutMs` ms, then return `Timeout=true` if no chunk arrived (heartbeat). | + +`Timeout=true` is **not** an error — it just means "no data yet." Use it to +periodically check a `CancellationToken` from your read loop without blocking +the underlying P/Invoke thread indefinitely. + +--- + +## 🧩 Single-thread tunneling + +A common pattern with `TlsClient.Native` is to serialize every native call +through a single dedicated thread (to keep the cgo boundary simple). The +streaming API fits that model: + +* `RequestStream`, `ReadStream`, `ReadStreamAll`, and `CancelStream` are all + short-lived calls bounded by `timeoutMs`. +* The Go side keeps a per-stream goroutine that pumps body bytes into a + buffered channel — `ReadStream` just pops the next chunk. +* You can safely interleave streaming reads with normal `Request()` calls on + the same dispatcher. + +--- + +## ⚙️ Tuning + +| Knob | Where | Effect | +| ---- | ----- | ------ | +| `Request.StreamOutputBlockSize` | per request | Per-Read buffer size on the Go side. Defaults to **4096 bytes**. SSE rarely benefits from making it smaller; large binary streams may benefit from `64 * 1024`. | +| `ReadStream(streamId, timeoutMs)` | per call | How often the read loop wakes up to check cancellation. Typical: **250–1000 ms**. | +| `Request.TimeoutMilliseconds` / `TlsClientOptions.Timeout` | per client/request | ⚠️ **Set to zero for streaming.** Go's `http.Client.Timeout` covers the entire request *including* body reads, so a 30s timeout will tear down a long-lived SSE stream. | + +--- + +## 📖 Reference (returned types) + +### `StreamStartResponse` +Extends `Response` with one extra field: `Guid StreamId`. `Body` is always empty. + +### `StreamChunkResponse` +| Field | Meaning | +| ------------ | ------------------------------------------------------------------------- | +| `StreamId` | Echo of the input id. | +| `Chunk` | Base64-encoded raw bytes (decompressed). Empty on EOF / Timeout / Error. | +| `EOF` | `true` once. Stream is closed; `StreamId` is invalid after this. | +| `Timeout` | `true` when no data arrived within `timeoutMs`. Stream still live. | +| `Error` | Non-empty on a fatal read error. Stream is closed. | +| `IsTerminal` | Convenience: `EOF || !string.IsNullOrEmpty(Error)`. | +| `GetChunkBytes()` | `Convert.FromBase64String(Chunk)` with a null-safe fallback. | + +`CancelStream` returns the existing `DestroyResponse` (`{ Id, Body, Success }`). + +--- + +## 🚧 Limitations + +* **No callbacks across the FFI boundary** — the model is poll-based, by + design. Reverse P/Invoke into managed code from arbitrary Go goroutine + threads is fragile and would break the single-thread tunneling pattern. +* **Backpressure timeout** — if you stop calling `ReadStream` for >60 s, the + Go-side pump goroutine cancels itself to avoid leaking goroutines. Subsequent + `ReadStream` calls will return `EOF=true`. +* **One reader per stream** — concurrent `ReadStream` calls on the same + `StreamId` are serialized internally but produce undefined chunk ordering. + Don't do it. + +--- + +## 🧯 Troubleshooting + +| Symptom | Likely cause | +| -------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `EntryPointNotFoundException: requestStream` | The loaded native library predates the streaming exports. Use a v1.11.2-stream or later DLL. | +| Stream ends after exactly 30 s | `TlsClientOptions.Timeout` / `Request.TimeoutMilliseconds` is non-zero. Set it to `0`. | +| Endless `Timeout=true` heartbeats | The remote side hasn't flushed any bytes yet. Verify the endpoint actually streams. | +| `Error: unknown streamId` | You called `ReadStream` after `EOF` / `Error` / `CancelStream`. Stop the loop on those. | diff --git a/readme.md b/readme.md index 6ec5f59..6e96646 100644 --- a/readme.md +++ b/readme.md @@ -113,6 +113,17 @@ Console.WriteLine(res.Status); --- +## 🔀 Streaming Responses + +`TlsClient.Native` supports consuming streaming responses (Server-Sent Events, +NDJSON, chunked bodies) without buffering the whole body — through a +`RequestStream` / `ReadStream` / `CancelStream` API on `NativeTlsClient`. + +See [docs/streaming.md](./docs/streaming.md) for the full guide, including SSE +examples, `Content-Type`-based branching, tuning knobs, and limitations. + +--- + ## 🧯 Support & Issues * Wrapper/packaging issues → open an issue **here**. diff --git a/src/TlsClient.Core/Models/Requests/CancelStreamRequest.cs b/src/TlsClient.Core/Models/Requests/CancelStreamRequest.cs new file mode 100644 index 0000000..46e9f1e --- /dev/null +++ b/src/TlsClient.Core/Models/Requests/CancelStreamRequest.cs @@ -0,0 +1,10 @@ +using System; + +namespace TlsClient.Core.Models.Requests +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (CancelStreamInput) + public class CancelStreamRequest + { + public Guid StreamId { get; set; } + } +} diff --git a/src/TlsClient.Core/Models/Requests/ReadStreamAllRequest.cs b/src/TlsClient.Core/Models/Requests/ReadStreamAllRequest.cs new file mode 100644 index 0000000..1c155e9 --- /dev/null +++ b/src/TlsClient.Core/Models/Requests/ReadStreamAllRequest.cs @@ -0,0 +1,10 @@ +using System; + +namespace TlsClient.Core.Models.Requests +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (ReadStreamAllInput) + public class ReadStreamAllRequest + { + public Guid StreamId { get; set; } + } +} diff --git a/src/TlsClient.Core/Models/Requests/ReadStreamRequest.cs b/src/TlsClient.Core/Models/Requests/ReadStreamRequest.cs new file mode 100644 index 0000000..4a439cd --- /dev/null +++ b/src/TlsClient.Core/Models/Requests/ReadStreamRequest.cs @@ -0,0 +1,20 @@ +using System; + +namespace TlsClient.Core.Models.Requests +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (ReadStreamInput) + public class ReadStreamRequest + { + public Guid StreamId { get; set; } + + /// + /// How long the native side should block waiting for the next chunk: + /// + /// < 0: block until the next chunk, EOF, or error. + /// = 0: non-blocking poll. Returns Timeout=true immediately when no chunk is buffered. + /// > 0: block up to TimeoutMs. Returns Timeout=true if no chunk arrived in time. + /// + /// + public int TimeoutMs { get; set; } + } +} diff --git a/src/TlsClient.Core/Models/Responses/StreamChunkResponse.cs b/src/TlsClient.Core/Models/Responses/StreamChunkResponse.cs new file mode 100644 index 0000000..848e5a7 --- /dev/null +++ b/src/TlsClient.Core/Models/Responses/StreamChunkResponse.cs @@ -0,0 +1,48 @@ +using System; +using System.Text.Json.Serialization; + +namespace TlsClient.Core.Models.Responses +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (StreamChunkResponse) + /// + /// Returned by readStream. Exactly one of , , + /// , or a non-empty indicates the meaningful state: + /// + /// (base64) holds the next slice of decompressed body bytes. + /// = true means the stream completed naturally; the + /// is invalid after this call. + /// = true means no data was available within the + /// caller-provided timeout; the stream is still live and the caller may retry. + /// holds a non-empty message when the underlying read failed; + /// the is invalid after this call. + /// + /// + public class StreamChunkResponse : BaseResponse + { + public Guid StreamId { get; set; } + + /// Base64-encoded raw bytes. Empty when , , or + /// is set. Use for the decoded payload. + public string? Chunk { get; set; } + + [JsonPropertyName("eof")] + public bool EOF { get; set; } + + public bool Timeout { get; set; } + + public string? Error { get; set; } + + /// True when the chunk carries no payload (EOF/Timeout/Error or empty data). + [JsonIgnore] + public bool IsTerminal => EOF || !string.IsNullOrEmpty(Error); + + /// Decode the base64 into raw bytes. Returns an empty array if + /// the chunk is missing. + public byte[] GetChunkBytes() + { + if (string.IsNullOrEmpty(Chunk)) + return Array.Empty(); + return Convert.FromBase64String(Chunk); + } + } +} diff --git a/src/TlsClient.Core/Models/Responses/StreamStartResponse.cs b/src/TlsClient.Core/Models/Responses/StreamStartResponse.cs new file mode 100644 index 0000000..a4d2537 --- /dev/null +++ b/src/TlsClient.Core/Models/Responses/StreamStartResponse.cs @@ -0,0 +1,16 @@ +using System; + +namespace TlsClient.Core.Models.Responses +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (StreamStartResponse) + /// + /// Returned by requestStream. Carries the same fields as + /// (status, headers, cookies, target, used protocol) plus a + /// that identifies the open stream for subsequent ReadStream / ReadStreamAll / + /// CancelStream calls. is always empty here. + /// + public class StreamStartResponse : Response + { + public Guid StreamId { get; set; } + } +} diff --git a/src/TlsClient.Core/TlsClient.Core.csproj b/src/TlsClient.Core/TlsClient.Core.csproj index 08ac512..dfbf13d 100644 --- a/src/TlsClient.Core/TlsClient.Core.csproj +++ b/src/TlsClient.Core/TlsClient.Core.csproj @@ -2,6 +2,10 @@ netstandard2.1 enable + 1.1.0-stream.1 + ErenKrt + https://github.com/ErenKrt/TlsClient.NET + Shared models and helpers for TlsClient.NET. diff --git a/src/TlsClient.Native/NativeTlsClient.cs b/src/TlsClient.Native/NativeTlsClient.cs index dcaac8c..ebd18a9 100644 --- a/src/TlsClient.Native/NativeTlsClient.cs +++ b/src/TlsClient.Native/NativeTlsClient.cs @@ -88,5 +88,163 @@ public override DestroyResponse DestroyAll() public override Task DestroyAllAsync(CancellationToken ct = default) => AsyncHelpers.RunAsync(() => DestroyAll(), ct); public override Task AddCookiesAsync(string url, List cookies, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => AddCookies(url, cookies), ct); #endregion + + #region Streaming + /// + /// Returns true when the loaded native library exposes the streaming exports. + /// Older builds may not — in which case the streaming methods on this client throw + /// on first use. + /// + public static bool IsStreamingSupported => TlsClientWrapper.IsStreamingSupported; + + /// + /// Issues a request and returns once the response headers are available, without + /// reading the body. Use the returned + /// to drive subsequent / / + /// calls. + /// + /// + /// For server-sent events (Content-Type: text/event-stream) you must set + /// to 0 (or a deliberately large value). + /// The bound applies to the + /// whole request including body reads. + /// + public StreamStartResponse RequestStream(Request request) + { + request = PrepareRequest(request); + + StreamStartResponse response; + + try + { + var payload = RequestHelpers.Prepare(request); + var rawResponse = TlsClientWrapper.RequestStream(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new StreamStartResponse + { + Body = err.Message, + Status = 0, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + /// + /// Polls for the next chunk of an in-flight stream. Returns immediately on data, + /// EOF, error, or after when no chunk is available + /// (heartbeat poll). Inspect , + /// , and + /// to drive the read loop. + /// + /// + /// < 0: block until the next chunk, EOF, or error. + /// = 0: non-blocking poll. + /// > 0: block up to TimeoutMs, then return Timeout=true if no chunk arrived. + /// + public StreamChunkResponse ReadStream(Guid streamId, int timeoutMs = 1000) + { + StreamChunkResponse response; + + try + { + var payload = RequestHelpers.Prepare(new ReadStreamRequest + { + StreamId = streamId, + TimeoutMs = timeoutMs, + }); + var rawResponse = TlsClientWrapper.ReadStream(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new StreamChunkResponse + { + StreamId = streamId, + Error = err.Message, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + /// + /// Drains the rest of the stream's body in one call and returns it as a normal + /// (charset-decoded for text bodies, base64+MIME prefix for + /// byte responses). Use this when the response from + /// turns out to NOT be a streaming response (e.g. Content-Type isn't + /// text/event-stream) and you just want the full body in one shot. + /// After this returns, is invalid. + /// + public Response ReadStreamAll(Guid streamId) + { + Response response; + + try + { + var payload = RequestHelpers.Prepare(new ReadStreamAllRequest { StreamId = streamId }); + var rawResponse = TlsClientWrapper.ReadStreamAll(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new Response + { + Body = err.Message, + Status = 0, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + /// + /// Cancels an in-flight stream and releases the underlying connection. Idempotent — + /// safe to call after a natural EOF, after an error, or with an unknown + /// . Always call this in a finally block when consuming + /// chunks via . + /// + public DestroyResponse CancelStream(Guid streamId) + { + DestroyResponse response; + + try + { + var payload = RequestHelpers.Prepare(new CancelStreamRequest { StreamId = streamId }); + var rawResponse = TlsClientWrapper.CancelStream(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new DestroyResponse + { + Body = err.Message, + Success = false, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + public Task RequestStreamAsync(Request request, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => RequestStream(request), ct); + public Task ReadStreamAsync(Guid streamId, int timeoutMs = 1000, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => ReadStream(streamId, timeoutMs), ct); + public Task ReadStreamAllAsync(Guid streamId, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => ReadStreamAll(streamId), ct); + public Task CancelStreamAsync(Guid streamId, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => CancelStream(streamId), ct); + #endregion } } diff --git a/src/TlsClient.Native/TlsClient.Native.csproj b/src/TlsClient.Native/TlsClient.Native.csproj index 6ccecfe..8595382 100644 --- a/src/TlsClient.Native/TlsClient.Native.csproj +++ b/src/TlsClient.Native/TlsClient.Native.csproj @@ -5,8 +5,8 @@ enable TlsClient.Native ErenKrt + 1.1.0-stream.1 https://github.com/ErenKrt/TlsClient.NET - True TlsClient.Native is a .NET Standard library that provides direct/native bindings to the TlsClient Go library. README.md LICENSE diff --git a/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs b/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs index c1c08ab..8ab8014 100644 --- a/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs +++ b/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs @@ -24,6 +24,19 @@ public static class TlsClientWrapper [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr DestroyAllDelegate(); + // Streaming exports (added in tls-client v1.11.2-stream) + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr RequestStreamDelegate(byte[] payload); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr ReadStreamDelegate(byte[] payload); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr ReadStreamAllDelegate(byte[] payload); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr CancelStreamDelegate(byte[] payload); + private static bool _isInitialized; private static IntPtr _module; @@ -33,6 +46,12 @@ public static class TlsClientWrapper private static AddCookiesToSessionDelegate _addCookiesToSessionDelegate = null!; private static DestroySessionDelegate _destroySessionDelegate = null!; private static DestroyAllDelegate _destroyAllDelegate = null!; + // Nullable: may remain null after Initialize when the loaded native library predates + // the streaming exports. RequireStreamingDelegate enforces non-null on first use. + private static RequestStreamDelegate? _requestStreamDelegate; + private static ReadStreamDelegate? _readStreamDelegate; + private static ReadStreamAllDelegate? _readStreamAllDelegate; + private static CancelStreamDelegate? _cancelStreamDelegate; public static void Initialize(string? libraryPath = null) { @@ -52,6 +71,13 @@ public static void Initialize(string? libraryPath = null) _destroySessionDelegate = GetDelegate("destroySession"); _destroyAllDelegate = GetDelegate("destroyAll"); + // Streaming exports — optional. Older native libraries don't expose these, + // so the lookup is allowed to fail silently. The streaming wrapper methods + // re-check and throw a clearer error when actually invoked. + _requestStreamDelegate = TryGetDelegate("requestStream"); + _readStreamDelegate = TryGetDelegate("readStream"); + _readStreamAllDelegate = TryGetDelegate("readStreamAll"); + _cancelStreamDelegate = TryGetDelegate("cancelStream"); _isInitialized = true; } @@ -72,6 +98,25 @@ private static T GetDelegate(string functionName) where T : Delegate return Marshal.GetDelegateForFunctionPointer(functionPtr); } + private static T? TryGetDelegate(string functionName) where T : Delegate + { + var functionPtr = NativeLoader.GetProcAddress(_module, functionName); + if (functionPtr == IntPtr.Zero) + return null; + + return Marshal.GetDelegateForFunctionPointer(functionPtr); + } + + private static T RequireStreamingDelegate(T? del, string functionName) where T : Delegate + { + if (del is null) + throw new EntryPointNotFoundException( + $"Native function '{functionName}' is not exported by the loaded tls-client library. " + + "Streaming requires a native library built with the streaming exports (requestStream, " + + "readStream, readStreamAll, cancelStream). Update your native library."); + return del; + } + private static string ExecuteNative(Func nativeCall) { var ptr = nativeCall(); @@ -123,6 +168,56 @@ public static string DestroyAll() EnsureInitialized(); return ExecuteNative(() => _destroyAllDelegate()); } + + public static string RequestStream(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_requestStreamDelegate, "requestStream"); + return ExecuteNative(() => del(payload)); + } + + public static string ReadStream(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_readStreamDelegate, "readStream"); + return ExecuteNative(() => del(payload)); + } + + public static string ReadStreamAll(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_readStreamAllDelegate, "readStreamAll"); + return ExecuteNative(() => del(payload)); + } + + public static string CancelStream(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_cancelStreamDelegate, "cancelStream"); + return ExecuteNative(() => del(payload)); + } + + /// + /// True when the loaded native library exposes the streaming exports + /// (requestStream, readStream, readStreamAll, cancelStream). + /// Older builds of the tls-client library may not expose these, in which case the streaming + /// methods on will throw on first use. + /// + public static bool IsStreamingSupported + { + get + { + EnsureInitialized(); + return _requestStreamDelegate != null + && _readStreamDelegate != null + && _readStreamAllDelegate != null + && _cancelStreamDelegate != null; + } + } public static void Destroy() { if (!_isInitialized) @@ -146,6 +241,10 @@ public static void Destroy() _addCookiesToSessionDelegate = null!; _destroySessionDelegate = null!; _destroyAllDelegate = null!; + _requestStreamDelegate = null; + _readStreamDelegate = null; + _readStreamAllDelegate = null; + _cancelStreamDelegate = null; _module = IntPtr.Zero; diff --git a/src/native/template/{title}.csproj b/src/native/template/{title}.csproj index 00e08e8..d69738f 100644 --- a/src/native/template/{title}.csproj +++ b/src/native/template/{title}.csproj @@ -2,9 +2,9 @@ netstandard2.1 false - true {title} ErenKrt + {packageVersion} https://github.com/ErenKrt/TlsClient.NET Native client of TLSClient for {os} {arch} diff --git a/tls-client-version.txt b/tls-client-version.txt index 07fb54b..436799e 100644 --- a/tls-client-version.txt +++ b/tls-client-version.txt @@ -1 +1 @@ -v1.11.2 +v1.11.2-stream From ccb2e31aac96cbc2251c1fc93e02606ffbf70f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 11:45:25 +0200 Subject: [PATCH 2/9] feat(core): add Chrome 146, Brave 146, Safari iOS 26 and Firefox 148 client identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync TlsClientIdentifier with the latest entries in the underlying tls-client library's MappedTLSClients map: Chrome146 chrome_146 Chrome146Psk chrome_146_PSK Brave146 brave_146 (new Brave region; Chromium-derived) Brave146Psk brave_146_PSK SafariIos260 safari_ios_26_0 Firefox148 firefox_148 Purely additive — no existing constants are removed and no defaults change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/Entities/TlsClientIdentifier.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs b/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs index c43444d..b32298f 100644 --- a/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs +++ b/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs @@ -27,6 +27,13 @@ public sealed class TlsClientIdentifier public static readonly TlsClientIdentifier Chrome133Psk = new TlsClientIdentifier("chrome_133_PSK"); public static readonly TlsClientIdentifier Chrome144 = new TlsClientIdentifier("chrome_144"); public static readonly TlsClientIdentifier Chrome144Psk = new TlsClientIdentifier("chrome_144_PSK"); + public static readonly TlsClientIdentifier Chrome146 = new TlsClientIdentifier("chrome_146"); + public static readonly TlsClientIdentifier Chrome146Psk = new TlsClientIdentifier("chrome_146_PSK"); + #endregion + + #region Brave Profiles + public static readonly TlsClientIdentifier Brave146 = new TlsClientIdentifier("brave_146"); + public static readonly TlsClientIdentifier Brave146Psk = new TlsClientIdentifier("brave_146_PSK"); #endregion #region Safari Profiles @@ -39,6 +46,7 @@ public sealed class TlsClientIdentifier public static readonly TlsClientIdentifier SafariIos170 = new TlsClientIdentifier("safari_ios_17_0"); public static readonly TlsClientIdentifier SafariIos180 = new TlsClientIdentifier("safari_ios_18_0"); public static readonly TlsClientIdentifier SafariIos185 = new TlsClientIdentifier("safari_ios_18_5"); + public static readonly TlsClientIdentifier SafariIos260 = new TlsClientIdentifier("safari_ios_26_0"); #endregion #region Firefox Profiles @@ -57,6 +65,7 @@ public sealed class TlsClientIdentifier public static readonly TlsClientIdentifier Firefox146Psk = new TlsClientIdentifier("firefox_146_PSK"); public static readonly TlsClientIdentifier Firefox147 = new TlsClientIdentifier("firefox_147"); public static readonly TlsClientIdentifier Firefox147Psk = new TlsClientIdentifier("firefox_147_PSK"); + public static readonly TlsClientIdentifier Firefox148 = new TlsClientIdentifier("firefox_148"); #endregion #region Opera Profiles From 096333c2a8665444ac7b2d5baca31d2f3972b61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 11:47:27 +0200 Subject: [PATCH 3/9] refactor(core): drop stale Chrome132 client identifier; default to Chrome133 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chrome_132 entry was removed from the underlying tls-client library's MappedTLSClients map at some point. Sending it across the FFI silently falls through to DefaultClientProfile (currently Chrome_133), which means every default-constructed BaseTlsClient was effectively running with a Chrome 133 fingerprint while still claiming to be Chrome 132. Align the C# layer with what the native side actually uses: * Remove TlsClientIdentifier.Chrome132 (no longer mapped by Go). * BaseTlsClient parameterless constructor now uses Chrome133, with the User-Agent bumped from Chrome/132.0.0.0 / OPR/117 to Chrome/133.0.0.0 / OPR/118 to keep the identifier and UA coherent. * Update test fixtures (TlsTests, PerformanceTests) that referenced Chrome132 directly so the project still compiles. This is a breaking change for anyone explicitly referencing TlsClientIdentifier.Chrome132 in their own code. The behavioural impact is nil — those callers were already getting Chrome 133 at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TlsClient.Core/BaseTlsClient.cs | 2 +- src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs | 1 - tests/TlsClient.Native.Tests/PerformanceTests.cs | 4 ++-- tests/TlsClient.Native.Tests/TlsTests.cs | 6 +++--- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/TlsClient.Core/BaseTlsClient.cs b/src/TlsClient.Core/BaseTlsClient.cs index 1692602..df8bbbf 100644 --- a/src/TlsClient.Core/BaseTlsClient.cs +++ b/src/TlsClient.Core/BaseTlsClient.cs @@ -19,7 +19,7 @@ protected BaseTlsClient(TlsClientOptions options) Options = options ?? throw new ArgumentNullException(nameof(options)); } - protected BaseTlsClient(): this(new TlsClientOptions(TlsClientIdentifier.Chrome132, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0")){} + protected BaseTlsClient(): this(new TlsClientOptions(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0")){} public abstract Response Request(Request request); public abstract GetCookiesFromSessionResponse GetCookies(string url); diff --git a/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs b/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs index b32298f..a242a9c 100644 --- a/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs +++ b/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs @@ -21,7 +21,6 @@ public sealed class TlsClientIdentifier public static readonly TlsClientIdentifier Chrome124 = new TlsClientIdentifier("chrome_124"); public static readonly TlsClientIdentifier Chrome130Psk = new TlsClientIdentifier("chrome_130_PSK"); public static readonly TlsClientIdentifier Chrome131 = new TlsClientIdentifier("chrome_131"); - public static readonly TlsClientIdentifier Chrome132 = new TlsClientIdentifier("chrome_132"); public static readonly TlsClientIdentifier Chrome131Psk = new TlsClientIdentifier("chrome_131_PSK"); public static readonly TlsClientIdentifier Chrome133 = new TlsClientIdentifier("chrome_133"); public static readonly TlsClientIdentifier Chrome133Psk = new TlsClientIdentifier("chrome_133_PSK"); diff --git a/tests/TlsClient.Native.Tests/PerformanceTests.cs b/tests/TlsClient.Native.Tests/PerformanceTests.cs index 2e25ded..c451bcc 100644 --- a/tests/TlsClient.Native.Tests/PerformanceTests.cs +++ b/tests/TlsClient.Native.Tests/PerformanceTests.cs @@ -36,7 +36,7 @@ public async Task Should_Perform_Multiple_Requests() { using var tlsClient = new NativeTlsClient(new TlsClientOptions { - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, Timeout = TimeSpan.FromSeconds(10) }); @@ -80,7 +80,7 @@ public async Task Should_Not_Leak_Memory() { using var tlsClient = new NativeTlsClient(new TlsClientOptions { - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, Timeout = TimeSpan.FromSeconds(10) }); diff --git a/tests/TlsClient.Native.Tests/TlsTests.cs b/tests/TlsClient.Native.Tests/TlsTests.cs index db94620..7f92211 100644 --- a/tests/TlsClient.Native.Tests/TlsTests.cs +++ b/tests/TlsClient.Native.Tests/TlsTests.cs @@ -37,7 +37,7 @@ public void Should_Skip_Verify_Ssl() using var tlsClient = new NativeTlsClient(new TlsClientOptions() { InsecureSkipVerify = true, - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, }); var request = new Request() { @@ -53,7 +53,7 @@ public void Should_Force_Http1() using var tlsClient = new NativeTlsClient(new TlsClientOptions() { ForceHttp1 = true, - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, }); var request = new Request() { @@ -69,7 +69,7 @@ public void Should_Not_Force_Http1() { using var tlsClient = new NativeTlsClient(new TlsClientOptions() { - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, }); var request = new Request() { From e3b6196a8b05c7eabbbf47e5aa883988badb3761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 11:51:59 +0200 Subject: [PATCH 4/9] fix: align all Chrome 133 / OPR 118 user-agent literals; refresh stale doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Chrome132 removal. Two related fixes: * Native.Tests/HeaderTests.Should_Add_UserAgent_Header_Default was asserting the default UA contains "Chrome/132.0.0.0 ... OPR/117.0.0.0", but BaseTlsClient's default ctor now emits the Chrome 133 / OPR 118 string. The assertion was stale and would have failed at runtime. `dotnet build` did not catch it. * On the API side, the parameterless ApiTlsClient / ApiTlsClientOptions constructors paired Chrome133 (TLS identifier) with Chrome/132.0.0.0 in the User-Agent — a fingerprint/UA mismatch that predates this cleanup but is fixed here for coherence with the Native default. Changes: - ApiTlsClient(Uri, string) and ApiTlsClientOptions(Uri, string): UA bumped from Chrome/132.0.0.0 / OPR/117.0.0.0 to Chrome/133.0.0.0 / OPR/118.0.0.0. - Native and API HeaderTests UA literals updated to match the new defaults. - src/TlsClient.Api/README.md sample updated to match. - src/Providers/TlsClient.Provider.HttpClient/README.md: replaced `TlsClientIdentifier.Chrome132` (deleted by the previous commit, so the snippet would no longer compile) with `Chrome133`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Providers/TlsClient.Provider.HttpClient/README.md | 2 +- src/TlsClient.Api/ApiTlsClient.cs | 2 +- src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs | 2 +- src/TlsClient.Api/README.md | 2 +- tests/TlsClient.Api.Tests/HeaderTests.cs | 2 +- tests/TlsClient.Native.Tests/HeaderTests.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Providers/TlsClient.Provider.HttpClient/README.md b/src/Providers/TlsClient.Provider.HttpClient/README.md index 74da184..e203a43 100644 --- a/src/Providers/TlsClient.Provider.HttpClient/README.md +++ b/src/Providers/TlsClient.Provider.HttpClient/README.md @@ -31,7 +31,7 @@ TlsClient.Initialize("{LIBRARY_PATH}"); // create a TlsClient instance var tlsClient = new TlsClientBuilder() - .WithIdentifier(TlsClientIdentifier.Chrome132) + .WithIdentifier(TlsClientIdentifier.Chrome133) .WithUserAgent("TestClient 1.0") .Build(); diff --git a/src/TlsClient.Api/ApiTlsClient.cs b/src/TlsClient.Api/ApiTlsClient.cs index 271a39b..9f25e4a 100644 --- a/src/TlsClient.Api/ApiTlsClient.cs +++ b/src/TlsClient.Api/ApiTlsClient.cs @@ -27,7 +27,7 @@ public ApiTlsClient(ApiTlsClientOptions options) : base(options) { HttpClient.DefaultRequestHeaders.Add("x-api-key", options.ApiKey); } - public ApiTlsClient(Uri apiBaseUri, string apiKey) : this(new ApiTlsClientOptions(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0", apiBaseUri, apiKey)){ } + public ApiTlsClient(Uri apiBaseUri, string apiKey) : this(new ApiTlsClientOptions(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0", apiBaseUri, apiKey)){ } #region Sync Methods public override Response Request(Request request) => AsyncHelpers.RunSync(() => RequestAsync(request, CancellationToken.None)); diff --git a/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs b/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs index 8dbf071..bc39979 100644 --- a/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs +++ b/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs @@ -8,7 +8,7 @@ public class ApiTlsClientOptions : TlsClientOptions public Uri ApiBaseUri { get; internal set; } public string ApiKey { get; internal set; } - public ApiTlsClientOptions(Uri apiBaseUri, string apiKey) : base(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0") + public ApiTlsClientOptions(Uri apiBaseUri, string apiKey) : base(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0") { ApiBaseUri = ValidateBaseUri(apiBaseUri, nameof(apiBaseUri)); ApiKey = ValidateApiKey(apiKey, nameof(apiKey)); diff --git a/src/TlsClient.Api/README.md b/src/TlsClient.Api/README.md index 7ad3ef9..4f2b05d 100644 --- a/src/TlsClient.Api/README.md +++ b/src/TlsClient.Api/README.md @@ -44,7 +44,7 @@ using TlsClient.Core.Models.Requests; var options = new ApiTlsClientOptions( TlsClientIdentifier.Chrome133, - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0", new Uri("http://127.0.0.1:8080"), "my-auth-key-1" ); diff --git a/tests/TlsClient.Api.Tests/HeaderTests.cs b/tests/TlsClient.Api.Tests/HeaderTests.cs index 9e9e00c..0e205b3 100644 --- a/tests/TlsClient.Api.Tests/HeaderTests.cs +++ b/tests/TlsClient.Api.Tests/HeaderTests.cs @@ -51,7 +51,7 @@ public void Should_Add_UserAgent_Header_Options() [Fact] public void Should_Add_UserAgent_Header_Default() { - var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0"; + var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0"; using var tlsClient = new ApiTlsClient(new Uri("http://127.0.0.1:8080"), "my-auth-key-1"); var request = new Request() diff --git a/tests/TlsClient.Native.Tests/HeaderTests.cs b/tests/TlsClient.Native.Tests/HeaderTests.cs index 13e5c1b..77f1bc8 100644 --- a/tests/TlsClient.Native.Tests/HeaderTests.cs +++ b/tests/TlsClient.Native.Tests/HeaderTests.cs @@ -54,7 +54,7 @@ public void Should_Add_UserAgent_Header_Options() [Fact] public void Should_Add_UserAgent_Header_Default() { - var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0"; + var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0"; using var tlsClient = new NativeTlsClient(); var request = new Request() From 824cf465bf8902b2b86218ed3b21bcbd6854a581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 13:36:11 +0200 Subject: [PATCH 5/9] test: read native DLL path from TLS_CLIENT_NATIVE_DLL env var Every static fixture in TlsClient.Native.Tests and TlsClient.Native.RestSharp.Tests hardcoded the maintainer's local path: NativeTlsClient.Initialize("D:\Tools\tls-client-windows-64-1.13.1.dll"); That makes the suites unrunnable for any contributor (or CI runner) that doesn't replicate that exact path on disk. Introduce a tiny per-project NativeTestSetup helper that reads the TLS_CLIENT_NATIVE_DLL environment variable and falls back to the original hardcoded path when unset, so the existing maintainer workflow keeps working unchanged. Each fixture now calls: NativeTlsClient.Initialize(NativeTestSetup.DllPath); Set TLS_CLIENT_NATIVE_DLL to the DLL produced for your platform before running `dotnet test` to override the default. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BodyTests.cs | 2 +- .../CookieTests.cs | 2 +- .../MethodTests.cs | 2 +- .../NativeTestSetup.cs | 20 +++++++++++++++++++ .../StatusTests.cs | 2 +- tests/TlsClient.Native.Tests/BodyTests.cs | 2 +- tests/TlsClient.Native.Tests/BuilderTests.cs | 2 +- tests/TlsClient.Native.Tests/ClientTests.cs | 2 +- tests/TlsClient.Native.Tests/CookieTests.cs | 2 +- tests/TlsClient.Native.Tests/HeaderTests.cs | 2 +- tests/TlsClient.Native.Tests/MethodTests.cs | 2 +- .../TlsClient.Native.Tests/NativeTestSetup.cs | 20 +++++++++++++++++++ .../PerformanceTests.cs | 2 +- tests/TlsClient.Native.Tests/StatusTests.cs | 2 +- tests/TlsClient.Native.Tests/TlsTests.cs | 2 +- 15 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 tests/TlsClient.Native.RestSharp.Tests/NativeTestSetup.cs create mode 100644 tests/TlsClient.Native.Tests/NativeTestSetup.cs diff --git a/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs b/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs index f44c1d6..405fa2d 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs @@ -15,7 +15,7 @@ public class BodyTests { static BodyTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs b/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs index 019a0da..74bfdd7 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs @@ -17,7 +17,7 @@ public class CookieTests { static CookieTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs b/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs index 414691e..188b64e 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs @@ -15,7 +15,7 @@ public class MethodTests { static MethodTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.RestSharp.Tests/NativeTestSetup.cs b/tests/TlsClient.Native.RestSharp.Tests/NativeTestSetup.cs new file mode 100644 index 0000000..2f917fc --- /dev/null +++ b/tests/TlsClient.Native.RestSharp.Tests/NativeTestSetup.cs @@ -0,0 +1,20 @@ +using System; + +namespace TlsClient.RestSharp.Tests +{ + /// + /// Resolves the path to the native tls-client library used by the Native + /// RestSharp test fixtures. Reads the TLS_CLIENT_NATIVE_DLL + /// environment variable when set; otherwise falls back to a developer-machine + /// default that the upstream maintainer uses locally. CI / contributors + /// should set the env var to point at the DLL produced for their platform. + /// + internal static class NativeTestSetup + { + public const string EnvVar = "TLS_CLIENT_NATIVE_DLL"; + public const string DefaultDllPath = "D:\\Tools\\tls-client-windows-64-1.13.1.dll"; + + public static string DllPath => + Environment.GetEnvironmentVariable(EnvVar) ?? DefaultDllPath; + } +} diff --git a/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs b/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs index ac0d63b..0f72321 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs @@ -16,7 +16,7 @@ public class StatusTests { static StatusTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/BodyTests.cs b/tests/TlsClient.Native.Tests/BodyTests.cs index e168a85..96ab3f1 100644 --- a/tests/TlsClient.Native.Tests/BodyTests.cs +++ b/tests/TlsClient.Native.Tests/BodyTests.cs @@ -16,7 +16,7 @@ public class BodyTests { static BodyTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/BuilderTests.cs b/tests/TlsClient.Native.Tests/BuilderTests.cs index f805cf1..33aa22b 100644 --- a/tests/TlsClient.Native.Tests/BuilderTests.cs +++ b/tests/TlsClient.Native.Tests/BuilderTests.cs @@ -17,7 +17,7 @@ public class BuilderTests { static BuilderTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } diff --git a/tests/TlsClient.Native.Tests/ClientTests.cs b/tests/TlsClient.Native.Tests/ClientTests.cs index 38d53a0..b0a883d 100644 --- a/tests/TlsClient.Native.Tests/ClientTests.cs +++ b/tests/TlsClient.Native.Tests/ClientTests.cs @@ -10,7 +10,7 @@ public class ClientTests { static ClientTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/CookieTests.cs b/tests/TlsClient.Native.Tests/CookieTests.cs index c756a34..85a058d 100644 --- a/tests/TlsClient.Native.Tests/CookieTests.cs +++ b/tests/TlsClient.Native.Tests/CookieTests.cs @@ -17,7 +17,7 @@ public class CookieTests static CookieTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/HeaderTests.cs b/tests/TlsClient.Native.Tests/HeaderTests.cs index 77f1bc8..6b95b29 100644 --- a/tests/TlsClient.Native.Tests/HeaderTests.cs +++ b/tests/TlsClient.Native.Tests/HeaderTests.cs @@ -14,7 +14,7 @@ public class HeaderTests public static readonly string BaseURL = "https://httpbin.io"; static HeaderTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/MethodTests.cs b/tests/TlsClient.Native.Tests/MethodTests.cs index af2ef1e..dde2379 100644 --- a/tests/TlsClient.Native.Tests/MethodTests.cs +++ b/tests/TlsClient.Native.Tests/MethodTests.cs @@ -9,7 +9,7 @@ public class MethodTests public static readonly string BaseURL = "https://httpbin.io"; static MethodTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/NativeTestSetup.cs b/tests/TlsClient.Native.Tests/NativeTestSetup.cs new file mode 100644 index 0000000..6d35092 --- /dev/null +++ b/tests/TlsClient.Native.Tests/NativeTestSetup.cs @@ -0,0 +1,20 @@ +using System; + +namespace TlsClient.Core.Tests +{ + /// + /// Resolves the path to the native tls-client library used by the Native + /// test fixtures. Reads the TLS_CLIENT_NATIVE_DLL environment variable + /// when set; otherwise falls back to a developer-machine default that the + /// upstream maintainer uses locally. CI / contributors should set the env + /// var to point at the DLL produced for their platform. + /// + internal static class NativeTestSetup + { + public const string EnvVar = "TLS_CLIENT_NATIVE_DLL"; + public const string DefaultDllPath = "D:\\Tools\\tls-client-windows-64-1.13.1.dll"; + + public static string DllPath => + Environment.GetEnvironmentVariable(EnvVar) ?? DefaultDllPath; + } +} diff --git a/tests/TlsClient.Native.Tests/PerformanceTests.cs b/tests/TlsClient.Native.Tests/PerformanceTests.cs index c451bcc..4edae8c 100644 --- a/tests/TlsClient.Native.Tests/PerformanceTests.cs +++ b/tests/TlsClient.Native.Tests/PerformanceTests.cs @@ -17,7 +17,7 @@ public class PerformanceTests { static PerformanceTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/StatusTests.cs b/tests/TlsClient.Native.Tests/StatusTests.cs index b43abe4..7b0ab44 100644 --- a/tests/TlsClient.Native.Tests/StatusTests.cs +++ b/tests/TlsClient.Native.Tests/StatusTests.cs @@ -10,7 +10,7 @@ public class StatusTests public static readonly string BaseURL = "https://httpbin.io"; static StatusTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/TlsTests.cs b/tests/TlsClient.Native.Tests/TlsTests.cs index 7f92211..4fa12af 100644 --- a/tests/TlsClient.Native.Tests/TlsTests.cs +++ b/tests/TlsClient.Native.Tests/TlsTests.cs @@ -15,7 +15,7 @@ public class TlsTests { static TlsTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] From 145edd648f4da2784513991b879cd77006652c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 13:36:26 +0200 Subject: [PATCH 6/9] chore: bump to 1.2.0-stream.1; sync to tls-client v1.14.0-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Underlying tls-client-version.txt rolls v1.11.2-stream → v1.14.0-stream, picking up the new Chrome 146 / Brave 146 / Safari iOS 26 / Firefox 148 profiles, the Chrome 132 removal that already landed in this fork, and the streaming-related TimeoutSeconds semantics tweak (negative value disables the deadline for long-lived SSE streams). The wrapper packages bump from 1.1.0-stream.1 to 1.2.0-stream.1: - TlsClient.Core - TlsClient.Native Minor bump reflects the additive identifier surface (commit ccb2e31), the breaking Chrome132 removal (commit 096333c), and the test-infra refactor (commit 824cf46). The platform-specific package (TlsClient.Native.win-x64) is regenerated from the template by build/native-builder/prepare.js with PACKAGE_VERSION=1.2.0-stream.1 and is not tracked in git. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TlsClient.Core/TlsClient.Core.csproj | 2 +- src/TlsClient.Native/TlsClient.Native.csproj | 2 +- tls-client-version.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TlsClient.Core/TlsClient.Core.csproj b/src/TlsClient.Core/TlsClient.Core.csproj index dfbf13d..90c4baf 100644 --- a/src/TlsClient.Core/TlsClient.Core.csproj +++ b/src/TlsClient.Core/TlsClient.Core.csproj @@ -2,7 +2,7 @@ netstandard2.1 enable - 1.1.0-stream.1 + 1.2.0-stream.1 ErenKrt https://github.com/ErenKrt/TlsClient.NET Shared models and helpers for TlsClient.NET. diff --git a/src/TlsClient.Native/TlsClient.Native.csproj b/src/TlsClient.Native/TlsClient.Native.csproj index 8595382..3dd45a4 100644 --- a/src/TlsClient.Native/TlsClient.Native.csproj +++ b/src/TlsClient.Native/TlsClient.Native.csproj @@ -5,7 +5,7 @@ enable TlsClient.Native ErenKrt - 1.1.0-stream.1 + 1.2.0-stream.1 https://github.com/ErenKrt/TlsClient.NET TlsClient.Native is a .NET Standard library that provides direct/native bindings to the TlsClient Go library. README.md diff --git a/tls-client-version.txt b/tls-client-version.txt index 436799e..08e9010 100644 --- a/tls-client-version.txt +++ b/tls-client-version.txt @@ -1 +1 @@ -v1.11.2-stream +v1.14.0-stream \ No newline at end of file From 79880cdb5eb6294bc29032ae989e2b6d8165eced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 13:37:58 +0200 Subject: [PATCH 7/9] test: resolve httpbin IP at runtime in Should_Override_Host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture hardcoded 35.169.229.34 as the target IP. The IP is structurally necessary — the test verifies that RequestHostOverride actually populates the Host: header, which can only be observed when the connection bypasses DNS (otherwise the header derives from the URL and the override does nothing). The hardcoded value, though, was an AWS load balancer address that no longer routes to httpbin, so the test failed environmentally. Resolve httpbin.org's current IPv4 address via Dns.GetHostAddresses at test time. The test stays a real exercise of host override and now self-heals when the upstream CDN rotates addresses. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/TlsClient.Native.Tests/HeaderTests.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/TlsClient.Native.Tests/HeaderTests.cs b/tests/TlsClient.Native.Tests/HeaderTests.cs index 6b95b29..405c6d4 100644 --- a/tests/TlsClient.Native.Tests/HeaderTests.cs +++ b/tests/TlsClient.Native.Tests/HeaderTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Sockets; using System.Text; using System.Threading.Tasks; using TlsClient.Core.Models.Entities; @@ -84,18 +86,23 @@ public void Should_Add_UserAgent_After_Ctor() [Fact] public void Should_Override_Host() { - var baseHost = "httpbin.org"; - var realIp = "http://35.169.229.34"; + // Connect to the literal IP so DNS does not produce the Host header + // for us — that's what makes this an actual test of RequestHostOverride. + // Resolve the IP at runtime so the test self-heals when the upstream + // CDN / load balancer rotates addresses. + const string baseHost = "httpbin.org"; + var ip = Dns.GetHostAddresses(baseHost) + .First(a => a.AddressFamily == AddressFamily.InterNetwork); using var tlsClient = new NativeTlsClient(); var request = new Request() { - RequestUrl = realIp, - RequestHostOverride= baseHost, - InsecureSkipVerify= true + RequestUrl = $"http://{ip}", + RequestHostOverride = baseHost, + InsecureSkipVerify = true, }; var response = tlsClient.Request(request); - Assert.Contains($"httpbin", response.Body); + Assert.Contains("httpbin", response.Body); } } } \ No newline at end of file From 0af331750018bddc6ff64a469f1ff6ace30c554a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 14:42:13 +0200 Subject: [PATCH 8/9] docs(core): document Request.StreamOutput* properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three Stream* knobs on Request — StreamOutputPath, StreamOutputBlockSize and StreamOutputEOFSymbol — were public surface but had no XML docs at all. Now that the underlying native side reliably preserves binary content on the stream-to-file path (tls-client commit 1322ba3 dropped the binary- corrupting charset.NewReader from that path), capture the contract: StreamOutputPath • response body is streamed to disk; Response.Body is empty • bytes are byte-exact (post-fix) — including binary content • file is opened with O_APPEND, so a stale file is appended to • independent of the streaming RequestStream / ReadStream API StreamOutputBlockSize • per-Read buffer size on the native side • shared with the streaming API; defaults differ (1024 B stream-to-file / 4096 B streaming) StreamOutputEOFSymbol • optional completion sentinel for file tailers • ignored when StreamOutputPath is null Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TlsClient.Core/Models/Requests/Request.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/TlsClient.Core/Models/Requests/Request.cs b/src/TlsClient.Core/Models/Requests/Request.cs index dfd8ef2..f018808 100644 --- a/src/TlsClient.Core/Models/Requests/Request.cs +++ b/src/TlsClient.Core/Models/Requests/Request.cs @@ -26,8 +26,41 @@ public class Request public string? RequestBody { get; set; } = null; public string? RequestHostOverride { get; set; } = null; public Guid? SessionId { get; set; } + + /// + /// Per-Read buffer size used by the native side when writing to + /// or when chunking the body for the + /// streaming RequestStream / ReadStream API. Defaults to + /// 1024 bytes for stream-to-file and 4096 bytes for the streaming API. + /// public int? StreamOutputBlockSize { get; set; } = null; + + /// + /// Optional sentinel string appended to + /// after the body has been fully written, so consumers tailing the file + /// can detect completion without polling for size. Ignored when + /// is null. + /// public string? StreamOutputEOFSymbol { get; set; } = null; + + /// + /// When set, the response body is streamed to this file path on the + /// native side instead of being returned in + /// ; + /// is empty when this is non-null. + /// Bytes are written as the body arrives (after any automatic + /// decompression by the underlying HTTP transport), preserving the + /// original byte stream — binary content such as images is byte-exact. + /// + /// + /// The file is opened with O_APPEND | O_WRONLY | O_CREATE; if + /// the target already exists the new bytes are appended to it. The + /// caller is responsible for truncating or removing a stale file + /// before the request. This is independent of the streaming + /// RequestStream / ReadStream API on + /// NativeTlsClient; both can produce a file, but only the + /// streaming API hands chunks back to the .NET caller incrementally. + /// public string? StreamOutputPath { get; set; } = null; [JsonConverter(typeof(JsonStringConverter))] public HttpMethod RequestMethod { get; set; } = HttpMethod.Get; From 0f1268bda9a54c88480aafb812c5af04e1d79bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20H=C3=A4usl?= Date: Thu, 30 Apr 2026 14:42:47 +0200 Subject: [PATCH 9/9] docs(core): clarify Timeout semantics; TimeSpan.Zero is not "no timeout" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tls-client commit aa72213 reworked the cffi-side timeout resolver so TimeoutSeconds / TimeoutMilliseconds now carries a three-way meaning: null / 0 → use the native default (30 s) > 0 → explicit deadline (whole request, including body reads) < 0 → disable the deadline entirely (long-lived SSE / streaming) Several wrapper-side docs and snippets predated that change and told callers to "set Timeout to 0" / "TimeSpan.Zero" to disable the deadline. Under the new resolver that's wrong — 0 is interpreted as "use the default", and a long SSE stream gets cut at 30 s. The fix is to set the duration to a negative value; the idiomatic .NET sentinel Timeout.InfiniteTimeSpan (-1 ms) round-trips correctly through BaseTlsClient.PrepareRequest's (int)Options.Timeout.TotalMilliseconds. * docs/streaming.md - SSE example switches Timeout = TimeSpan.Zero → InfiniteTimeSpan - tuning table row spells out the three-way semantics explicitly - troubleshooting row corrects the "set it to 0" advice * NativeTlsClient.RequestStream XML doc - drops "set TimeoutMilliseconds to 0 (or a deliberately large value)"; explains the disable-via-InfiniteTimeSpan path * Request.TimeoutMilliseconds / TimeoutSeconds - new XML docs covering the three cases plus the InfiniteTimeSpan → -1 forwarding from TlsClientOptions * TlsClientOptions.Timeout - new XML doc calling out the TimeSpan.Zero foot-gun and pointing at Timeout.InfiniteTimeSpan for streaming Behaviour-preserving for the wrapper itself; only docs change. Existing callers that already pass a non-zero positive duration are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/streaming.md | 9 ++++++--- .../Models/Entities/TlsClientOptions.cs | 10 ++++++++++ src/TlsClient.Core/Models/Requests/Request.cs | 18 ++++++++++++++++++ src/TlsClient.Native/NativeTlsClient.cs | 12 ++++++++---- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index 6dfe7c2..ece6d54 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -54,7 +54,10 @@ using var client = new NativeTlsClient(new TlsClientOptions( TlsClientIdentifier.Chrome133, "Mozilla/5.0 ... Chrome/133") { - Timeout = TimeSpan.Zero, // ⚠️ streaming requires no client-wide timeout + // ⚠️ A negative duration disables the native http.Client.Timeout entirely. + // Required for long-lived SSE streams. TimeSpan.Zero does NOT work — it + // is treated as "use the 30 s default" by the native side. + Timeout = System.Threading.Timeout.InfiniteTimeSpan, }); var start = client.RequestStream(new Request @@ -163,7 +166,7 @@ streaming API fits that model: | ---- | ----- | ------ | | `Request.StreamOutputBlockSize` | per request | Per-Read buffer size on the Go side. Defaults to **4096 bytes**. SSE rarely benefits from making it smaller; large binary streams may benefit from `64 * 1024`. | | `ReadStream(streamId, timeoutMs)` | per call | How often the read loop wakes up to check cancellation. Typical: **250–1000 ms**. | -| `Request.TimeoutMilliseconds` / `TlsClientOptions.Timeout` | per client/request | ⚠️ **Set to zero for streaming.** Go's `http.Client.Timeout` covers the entire request *including* body reads, so a 30s timeout will tear down a long-lived SSE stream. | +| `Request.TimeoutMilliseconds` / `TlsClientOptions.Timeout` | per client/request | ⚠️ **Set to `Timeout.InfiniteTimeSpan` (or any negative duration) for long-lived streams.** Go's `http.Client.Timeout` covers the entire request *including* body reads, so a 30 s timeout will tear down a long-lived SSE stream. `TimeSpan.Zero` / `TimeoutMilliseconds = 0` is treated as "use the 30 s default" — only a negative value disables the deadline. | --- @@ -206,6 +209,6 @@ Extends `Response` with one extra field: `Guid StreamId`. `Body` is always empty | Symptom | Likely cause | | -------------------------------------------- | --------------------------------------------------------------------------------------------- | | `EntryPointNotFoundException: requestStream` | The loaded native library predates the streaming exports. Use a v1.11.2-stream or later DLL. | -| Stream ends after exactly 30 s | `TlsClientOptions.Timeout` / `Request.TimeoutMilliseconds` is non-zero. Set it to `0`. | +| Stream ends after exactly 30 s | The native side enforces its default 30 s deadline. Set `TlsClientOptions.Timeout = Timeout.InfiniteTimeSpan` (or `Request.TimeoutMilliseconds = -1`) to disable it. Note: `TimeSpan.Zero` / `0` is interpreted as "use the default", not "no timeout". | | Endless `Timeout=true` heartbeats | The remote side hasn't flushed any bytes yet. Verify the endpoint actually streams. | | `Error: unknown streamId` | You called `ReadStream` after `EOF` / `Error` / `CancelStream`. Stop the loop on those. | diff --git a/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs b/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs index e2cdd06..31b2824 100644 --- a/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs +++ b/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs @@ -12,6 +12,16 @@ public class TlsClientOptions public TlsClientIdentifier? TlsClientIdentifier { get; set; } = null; public string? ProxyURL { get; set; } public bool IsRotatingProxy { get; set; } = false; + /// + /// Per-request timeout applied to the whole HTTP exchange — including + /// body reads — by the underlying native client. Forwarded to the + /// native side as . + /// + /// Default: 60 s. + /// — interpreted by the native side as "use the 30 s default", NOT "no timeout". + /// (or any negative duration) — disables the deadline. Required for long-lived SSE / NDJSON / streaming responses, otherwise the connection will be torn down at the deadline. + /// + /// public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60); public string? ServerNameOverwrite { get; set; } public bool FollowRedirects { get; set; } = false; diff --git a/src/TlsClient.Core/Models/Requests/Request.cs b/src/TlsClient.Core/Models/Requests/Request.cs index f018808..34844c2 100644 --- a/src/TlsClient.Core/Models/Requests/Request.cs +++ b/src/TlsClient.Core/Models/Requests/Request.cs @@ -67,7 +67,25 @@ public class Request public string RequestUrl { get; set; } = string.Empty; public List? HeaderOrder { get; set; } = new List(); public List? RequestCookies { get; set; } = null; + /// + /// Per-request timeout in milliseconds. Takes precedence over + /// when both are set. Semantics: + /// + /// null or 0 — fall through to the native default (30 s). + /// > 0 — explicit deadline applied to the whole request, including body reads. + /// < 0 — disables the deadline entirely. Required for long-lived SSE / streaming responses. + /// + /// When forwarded from , + /// serializes to -1 here. + /// public int? TimeoutMilliseconds { get; set; } = null; + + /// + /// Per-request timeout in seconds. Yields to + /// when both are non-zero. Same three-way semantics as + /// : null/0 = native default, positive = deadline, + /// negative = disabled. + /// public int? TimeoutSeconds { get; set; } = null; public bool? CatchPanics { get; set; } = null; public bool? FollowRedirects { get; set; } = null; diff --git a/src/TlsClient.Native/NativeTlsClient.cs b/src/TlsClient.Native/NativeTlsClient.cs index ebd18a9..b4435d7 100644 --- a/src/TlsClient.Native/NativeTlsClient.cs +++ b/src/TlsClient.Native/NativeTlsClient.cs @@ -104,10 +104,14 @@ public override DestroyResponse DestroyAll() /// calls. /// /// - /// For server-sent events (Content-Type: text/event-stream) you must set - /// to 0 (or a deliberately large value). - /// The bound applies to the - /// whole request including body reads. + /// For long-lived streaming responses (server-sent events, NDJSON, etc.) + /// you must disable the native side's deadline by setting + /// to + /// — or by setting + /// / + /// to a negative value. + /// applies to the whole request including body reads, and the value 0 + /// is interpreted as "use the 30 s default", not "no timeout". /// public StreamStartResponse RequestStream(Request request) {