Skip to content

Use HttpClient for the hgresume resumable transport - #399

Open
hahn-kev wants to merge 12 commits into
masterfrom
perf/resume-transport-httpclient
Open

hahn-kev wants to merge 12 commits into
masterfrom
perf/resume-transport-httpclient

Conversation

@hahn-kev

@hahn-kev hahn-kev commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Use the modern http client instead of the old HttpWebRequest. This should also enable the client to use http 2. But this is mostly around modernizing.


AI-generated summary

Replaces the per-request HttpWebRequest in HgResumeRestApiServer with a process-wide static HttpClient:

  • Shared client + handler → keep-alive connection reuse across the many small requests a resumable push/pull makes (the real win on modern .NET, where the client runs over SocketsHttpHandler).
  • Credentials sent pre-emptively as a Basic Authorization header (UTF-8 encoded) — no 401 challenge round trip.
  • Per-call timeout via CancellationTokenSource (each API method carries its own secondsBeforeTimeout); a timeout still returns null.
  • ExpectContinue=false + ByteArrayContent keep the no-100-continue optimization and the Content-Length the server needs.
  • HttpClient doesn't throw on a non-2xx status, so RESET/FAIL responses flow through HandleResponse the way the old ProtocolError branch did. Only the x-hgr-* headers are copied into the WebHeaderCollection the response type expects, so HgResumeApiResponseHeaders and the test doubles are untouched.
  • HgResumeTransport's WebException safety nets now also catch HttpRequestException, which is what HttpClient throws for network failures.

Redirects. HttpClient drops the Authorization header when it follows a redirect, which the old NetworkCredential approach handled by answering the 401 challenge. The handler now has a SessionCredentials object that does the same:

  • It reads the current session's user and password on every challenge, since the handler outlives any one session.
  • It only answers for hosts an HgResumeRestApiServer was constructed for, and never on a downgrade from https to http, so a redirect elsewhere can't collect the password.
  • It derives from CredentialCache only because both .NET and .NET Framework refuse any other ICredentials after a redirect; the cache stays empty and our ICredentials.GetCredential answers.
  • 307/308 keep a push chunk's POST body. A 301/302 turns it into a GET, as before.
  • On .NET Framework the handler encodes the challenge answer as Latin-1 (as HttpWebRequest always did), so a non-ASCII password only works on the first, pre-emptive request there.

Integration tests. New LibChorus.IntegrationTests project runs ghcr.io/sillsdev/hgresume:v2026-08-24 via Testcontainers and drives push, multi-chunk push, clone and pull through HgRepository and the real transport. CI runs it on the Ubuntu job only, since the Windows runners can't host Linux containers. Locally it's ignored when Docker isn't available; HGRESUME_IMAGE overrides the image.

Test plan

  • Builds clean for net462 and netstandard2.0; CI green on Ubuntu net8.0, Windows net462 and Windows net8.0.
  • HgResumeRestApiServerTests pass on net8.0 and net462, including new tests for the UTF-8 Basic header, a 307 redirect that re-authenticates and keeps the POST body, and the host/https credential rules.
  • LibChorus.IntegrationTests (5 tests) pass against the hgresume container locally and in CI.
  • Full real-repo send/receive benchmark set (upload/download/checksync × elawa + sena-3) ran green against the hgresume Testcontainers server, with the source build confirmed live via the api pushBundleChunk/pullBundleChunk trace spans. No regression against the known baseline; cost is dominated by hg bundle/hg unbundle and raw chunk transfer, not the per-request overhead this change reduces.

🤖 Generated with Claude Code


This change is Reviewable

hahn-kev and others added 6 commits September 4, 2026 10:49
HgResumeRestApiServer.Execute is the single choke point for every call the
resumable transport makes — getRevisions, pushBundleChunk, pullBundleChunk,
finishPushBundle — and it was the one uninstrumented step on the send/receive
path: hg launches and chunk handling already open spans, so anything profiling
a push saw the HTTP time only as an unattributed remainder. Open a span here
too, tagged with the method, the bytes each way and the HTTP status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RevisionNumber's constructor called `hg log -rN --template "{node}"` to widen
the 12-character hash it had just been handed into the full 40-character node.
GetRevisionsFromQueryResultText builds one RevisionNumber per changeset, so a
single `hg log` listing N revisions was followed by N more hg processes that
told us nothing the first one couldn't have. Profiling a push of a 596-revision
FLEx project, those launches were 597 spans and 47% of the wall clock.

The detailed log template now asks for `{node}` alongside `{node|short}` and
the parser reads it, so the hash arrives with everything else. Two paths hand
us a full node to begin with — a parent's {p1node}, and hg debugancestor — and
those now use it directly instead of asking hg to repeat it. Whatever is left
(the untemplated `hg parent -r`, and external callers of the public parsing
method) falls back to the old lookup, but lazily, on first read of LongHash
rather than in the constructor. Nothing on the push, pull or check-sync path
reads LongHash without the template having filled it in, so in practice the
fallback never runs.

LongHash stays a public get/set property and the repository reference is a
private field, so revisioncache.json round-trips exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hg bundle` with no -t uses bzip2, the slowest compressor Mercurial ships.
Measured on a 635MB FLEx project it took 108s; zstd at level 5 took 37s for
11% more bytes, and zstd at its own default level took 20s for 14% more. That
trade pays for itself on any link faster than about 1MB/s and costs below it,
which is why the spec and level are ordinary settable fields: a caller whose
users are on genuinely poor connections can raise the level, or set the spec
to null and get hg's default back.

Both ends need the engine. zstd bundles have been readable since hg 4.1, and
the hgresume server's `hg incoming`/`hg unbundle` handle them without knowing
anything about how the bundle was made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things were costing round trips per chunk on a resumable push.

HttpWebRequest defaults to the 100-continue handshake, so every chunk body
waited for the server to say go ahead. Measured on a 635MB push that was about
17% of the time spent in pushBundleChunk. Write buffering is deliberately left
alone: turning it off made no measurable difference, and it would stop
HttpWebRequest replaying the body when a server answers the first POST with a
401.

PushStorageManager.GetChunk took whatever a single FileStream.Read returned. A
short read there is legal even when the bundle has more to give, and it costs a
whole extra request, since the client sends less than it asked the server to
expect. Read until the buffer is full or the file ends. Chunk count on the same
push fell from 66 to 41.

InitialChunkSize is left at 5000 and now says why: the first chunk goes out
before anything is known about the link and has to finish inside
TimeoutInSeconds on the slow connections this protocol exists for, and
CalculateChunkSize ramps away from it within a couple of round trips anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace the per-request HttpWebRequest in HgResumeRestApiServer with a
process-wide static HttpClient, so the many small requests a resumable
push/pull makes reuse keep-alive connections (the real win on modern .NET,
where the client runs over SocketsHttpHandler).

- Credentials are sent pre-emptively as a Basic Authorization header, so we
  never pay for a 401 challenge round trip. The resumable server accepts
  Basic auth on the first request, so PreAuthenticate's challenge-then-cache
  dance was pure overhead.
- Per-call timeout is enforced with a CancellationTokenSource rather than
  HttpClient.Timeout, since each API method carries its own
  secondsBeforeTimeout; a timeout returns null as before.
- ExpectContinue=false and ByteArrayContent (which sets Content-Length) keep
  the no-100-continue optimization and the Content-Length the server needs.
- HttpClient does not throw on a non-2xx status, so RESET/FAIL responses flow
  through HandleResponse the way the old ProtocolError branch did. Only the
  x-hgr-* headers are copied into the WebHeaderCollection the response type
  expects, leaving HgResumeApiResponseHeaders and the test doubles untouched.

HgResumeTransport's WebException safety nets now also catch HttpRequestException,
which is what HttpClient throws for network failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hahn-kev
hahn-kev marked this pull request as draft September 18, 2026 10:04
Base automatically changed from perf/faster-resumable-push to master September 25, 2026 03:27
hahn-kev and others added 3 commits September 25, 2026 10:50
HttpClient drops our pre-emptive Authorization header when it follows a
redirect. Give the handler credentials that read the current session, so
it can answer the 401 challenge at the new location. They are only
handed to hosts we were constructed for, and never on an https to http
downgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt-httpclient

# Conflicts:
#	src/LibChorus/VcsDrivers/Mercurial/HgResumeRestApiServer.cs
#	src/LibChorus/VcsDrivers/Mercurial/PushStorageManager.cs
New LibChorus.IntegrationTests project runs ghcr.io/sillsdev/hgresume in a
container via Testcontainers and drives push, pull and clone through
HgRepository and the real HgResumeTransport. CI runs it on Linux only,
since the Windows runners can't host Linux containers; locally the tests
are ignored when Docker isn't available.

Also use ASCII credentials in the redirect unit test: on .NET Framework
the handler encodes challenge answers as Latin-1, as HttpWebRequest did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Test Results

       9 files  +  1     347 suites  +1   1h 12m 37s ⏱️ -37s
1 042 tests +11     986 ✔️ +11    56 💤 ±0  0 ❌ ±0 
3 289 runs  +23  3 166 ✔️ +23  123 💤 ±0  0 ❌ ±0 

Results for commit defaa67. ± Comparison against base commit 116b10b.

♻️ This comment has been updated with latest results.

hahn-kev and others added 3 commits October 1, 2026 10:29
A process-wide HttpClient keeps connections, and their DNS answers,
forever. HgRepository makes a new HgResumeRestApiServer for each push,
pull or clone, so give each instance its own client: connections are
still reused across one operation's chunks. The transport now disposes
its server, and HgRepository disposes the transport. Redirect
credentials are scoped to the instance's own server.

Requests ask for HTTP/2, which an https server can accept via ALPN; plain
http and servers without it fall back to 1.1. Not on .NET Framework,
whose handler throws for 2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hahn-kev
hahn-kev requested a review from rmunn October 1, 2026 06:37
@hahn-kev
hahn-kev marked this pull request as ready for review October 1, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant