Skip to content

Add C# (ASP.NET Core) rewrite of the hgresume API - #19

Merged
hahn-kev merged 14 commits into
dockerizefrom
claude/csharp-rewrite
Aug 4, 2026
Merged

Add C# (ASP.NET Core) rewrite of the hgresume API#19
hahn-kev merged 14 commits into
dockerizefrom
claude/csharp-rewrite

Conversation

@hahn-kev

@hahn-kev hahn-kev commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Use aspnet for hgresumable


AI summary

Drop-in, wire-compatible C# / ASP.NET Core rewrite of the PHP hgresume API (the resumable Mercurial bundle-transfer backend used by Chorus / Language Depot / LexBox). It targets the behaviour of the dockerize (c905288) PHP lineage so existing field clients work unchanged. Authentication is intentionally left to the surrounding platform (no Apache/mod_perl/Redmine); deployment stays the Docker image.

What's here (all new code under csharp/; the PHP api/ tree is left in place for reference):

  • csharp/src/HgResume.Api (net10.0) — port of the six core classes:

    • RestDispatcher routes on the last path segment (/api/v03/<method>), binds query/body params PHP-style (including baseHashes[]), and writes the X-HgR-* response contract.
    • HgResumeApi — push/pull/getRevisions/finish*/isAvailable, a faithful port of the transaction state machine.
    • HgRunner shells out to hg (incoming / unbundle / bundle -t v1 / log / branches / tip), parsing stdout verbatim (same templates & regexes).
    • AsyncRunner runs long hg commands in the background and signals completion via a .async_run file (what makes transfers resumable), replacing the PHP GNU-time/shell-backgrounding trick with System.Diagnostics.Process.
    • BundleHelper — transaction state + metadata (JSON instead of PHP serialize()).
    • HgResumeResponse — status constants + PendingResponse.
  • Wire contract verified against the Chorus client (HgResumeTransport.cs, IApiServer.cs): URL shape, GET/POST text/plain, query param names, baseHashes[] encoding, and the x-hgr-* response headers. Notably, INPROGRESS and TIMEOUT share the const value 9, so a pending push maps to 202 (not 408) with sow = bundleSize - 10; the client reads that and resends the tail as a poll. This behaviour is reproduced exactly.

  • csharp/Dockerfile — multi-stage dotnet/sdk:10.0aspnet:10.0, installs mercurial, listens on port 80, and exposes the same /var/cache/hgresume + /var/vcs/public volumes, so it drops into the existing docker-compose.yaml.

  • csharp/test/HgResume.HttpTests — 35 HTTP-level xUnit tests ported from api/test/HgResumeApi_Test.php. A podman-managed fixture builds the image, runs the container, and seeds fixture repos via podman cp + podman exec unzip, then drives the protocol end-to-end. 35/35 pass against the built image.

  • CI (.github/workflows/docker-image.yml) — now builds the C# image (csharp/Dockerfile, amd64+arm64) and, on pull_request, pushes a pr-<number> tag to GHCR so the image can be pulled and tested in a real environment.

Test plan

  • cd csharp && ./run-tests.sh (or pwsh ./run-tests.ps1) — builds the image with podman and runs the HTTP-level suite against the container. Locally: 35/35 pass (~3 min; includes deliberately slow 50-byte-chunk transfers and the long-makeBundle INPROGRESS case).
  • Manual smoke: podman run -p 8034:80 hgresume-csharp:test, seed a repo, curl isAvailable / getRevisions — confirmed correct X-HgR-* headers and octet-stream body.
  • Once this PR builds, pull ghcr.io/sillsdev/hgresume:pr-<number> and point a real Chorus client at it for a full push + pull (including an interrupted/resumed transfer) before any cutover.

This change is Reviewable

Drop-in, wire-compatible reimplementation of the PHP hgresume API targeting the
dockerize (c905288) lineage: same /api/v03/<method> endpoints, X-HgR-* header
protocol, status-code mapping, and hg CLI behaviour, so existing Chorus clients
work unchanged. Auth is left to the surrounding platform.

- csharp/src/HgResume.Api: net10.0 ASP.NET Core port of RestServer, HgResumeApi,
  HgRunner, AsyncRunner, BundleHelper, HgResumeResponse. Contract verified
  against the Chorus client (incl. the PendingResponse -> 202 poll with
  sow = bundleSize - 10, and the INPROGRESS/TIMEOUT const collision).
- csharp/test/HgResume.HttpTests: 35 HTTP-level xUnit tests ported from
  api/test/HgResumeApi_Test.php, driven against the container via podman
  (podman cp/exec repo seeding). All 35 pass against the built image.
- csharp/Dockerfile: dotnet sdk/aspnet 10.0 + mercurial, port 80, same volumes
  as the PHP image (drop-in for docker-compose.yaml).
- CI: build the C# image and push a pr-<n> tag to GHCR on pull_request for
  real-environment testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hahn-kev and others added 12 commits July 31, 2026 19:27
Add a `test` job that builds the C# image (amd64, loaded into the runner's
docker) and runs the HTTP-level xUnit suite against it, driving the container
with docker via the fixture's HGRESUME_PODMAN override. The `build` (push) job
now depends on `test`, so images publish only when the suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Chorus resumable client reads response bodies with WebResponseHelper, which
returns EMPTY content when there is no Content-Length header (it never falls back
to chunked/Transfer-Encoding). The C# server wrote bodies via Body.WriteAsync
without a length, so Kestrel used chunked encoding -> the real client read every
body as empty -> getRevisions returned nothing -> "Push failed: A common revision
could not be found with the server" (and pulls got empty bundles).

Set Response.ContentLength before writing, as the PHP RestServer did.

- ContractFacts: raw-socket HTTP test asserting a body response carries
  Content-Length and is not chunked (fails without the fix; HttpClient-based
  tests can't catch it because HttpClient handles chunked transparently).
- ServerFixture: add HGRESUME_REPO_OWNER / HGRESUME_MAINT_PATH knobs (used when
  running the suite against the PHP reference image).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adapted from LexBox backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs.
Drives the real Chorus resumable client (HgResumeTransport via HgRepository)
against the C# hgresume container: clone / modify+send / send-new. LexBox's
server/API/auth coupling is replaced by creating repos with `hg init` in the
container; the project code contains "resumable" so Chorus selects its resumable
transport. LfMergeBridge is bypassed (its FixFwData fixup needs the full
FieldWorks stack and never touches the server); the resumable protocol exercised
is identical.

This harness reproduced the missing-Content-Length bug fixed in the previous
commit. Windows-only (Chorus pulls in .NET Framework deps) and needs podman + the
image, so it is not part of the Linux CI test job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The harness drives the Chorus hg repo + resumable transport directly
(HgRepository / HgResumeTransport), not the LfMergeBridge flow, so
SIL.ChorusPlugin.LfMergeBridge is unused. Remove it; only SIL.Chorus.LibChorus,
SIL.Chorus.Mercurial and SIL.Core are needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Push a project to the server, then clone it back into a fresh directory over the
resumable protocol and assert the cloned fwdata is byte-identical to what was
pushed. CloneProject now returns the actual clone path (Chorus may adjust it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Missing required params and BundleHelper ValidationException were escaping as bare HTTP 500s without protocol headers; mirror PHP RestServer::serverError instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
GetRepoPath now requires a single path segment via Path.GetFileName so callers cannot escape configured search roots.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fork PRs only get a read-only GITHUB_TOKEN; build still runs, but login/push only when the head repo matches.

Co-authored-by: Cursor <cursoragent@cursor.com>
Re-remove the registry entry if the task already completed before insert, and use value-conditional TryRemove.

Co-authored-by: Cursor <cursoragent@cursor.com>
Ignore test fixtures in the build context, and seed repos from the host so the production image only needs mercurial.

Co-authored-by: Cursor <cursoragent@cursor.com>
Expose HGRESUME_MAX_REQUEST_BODY_SIZE (default 30 MB) so the push body cap is explicit and tunable for non-Chorus clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
@megahirt

megahirt commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

cool to see this rewrite coming online!

At offset == bundleSize the server returns SUCCESS only once the transaction has
flipped from the Bundle to the Downloading state; until then it returns INPROGRESS
(which the real client polls through). The test asserted SUCCESS on the first
request, which is timing-dependent — it passed locally but failed intermittently
in CI. Poll via PullFirstChunk instead, matching the client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hahn-kev
hahn-kev merged commit 7053496 into dockerize Aug 4, 2026
4 checks passed
@hahn-kev
hahn-kev deleted the claude/csharp-rewrite branch August 4, 2026 03:12
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.

2 participants