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.
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:
if (!NativeTlsClient.IsStreamingSupported)
throw new NotSupportedException("Loaded native library does not expose the streaming entry points.");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")
{
// ⚠️ 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
{
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
}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:
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.
| 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.
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, andCancelStreamare all short-lived calls bounded bytimeoutMs.- The Go side keeps a per-stream goroutine that pumps body bytes into a
buffered channel —
ReadStreamjust pops the next chunk. - You can safely interleave streaming reads with normal
Request()calls on the same dispatcher.
| 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 | 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. |
Extends Response with one extra field: Guid StreamId. Body is always empty.
| 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 |
GetChunkBytes() |
Convert.FromBase64String(Chunk) with a null-safe fallback. |
CancelStream returns the existing DestroyResponse ({ Id, Body, Success }).
- 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
ReadStreamfor >60 s, the Go-side pump goroutine cancels itself to avoid leaking goroutines. SubsequentReadStreamcalls will returnEOF=true. - One reader per stream — concurrent
ReadStreamcalls on the sameStreamIdare serialized internally but produce undefined chunk ordering. Don't do it.
| 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 | 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. |