Skip to content

refactor: make the C# API properly async end-to-end - #22

Draft
hahn-kev wants to merge 6 commits into
dockerizefrom
claude/csharp-async-refactor-127165
Draft

refactor: make the C# API properly async end-to-end#22
hahn-kev wants to merge 6 commits into
dockerizefrom
claude/csharp-async-refactor-127165

Conversation

@hahn-kev

@hahn-kev hahn-kev commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

This should help us to avoid running out of threads, it also gives us cooperative cancellation, this should keep us from running work when the client aborts or cancels it's request.


🤖 AI summary

The C# API was a faithful, behavior-preserving rewrite of the PHP app in which only the HTTP entry layer (RestDispatcher) was async — everything below it ran synchronously and blocked request threads. Most notably, two Thread.Sleep poll loops sat on the request thread (up to 14s on the pull path, 5s on validate/unbundle), plus sync-over-async process execution and synchronous large-file streaming. This converts the whole request-handling chain to be async from top to bottom.

Changes (bottom-up):

  • ProcessRunnerRunSyncRunAsync using WaitForExitAsync; removed the .GetAwaiter().GetResult() sync-over-async.
  • AsyncRunnerWaitForIsCompleteWaitForIsCompleteAsync (Thread.SleepTask.Delay); async lock-file read/writes. The static-registry background-task design is intentionally kept (it already survives the originating request); its background write deliberately stays on CancellationToken.None.
  • HgRunner — revision/branch/validate/bundle methods are now async Task with a CancellationToken; pure launch methods stayed synchronous.
  • HgResumeApi — all endpoints are async Task<HgResumeResponse>; the 7×2s pull Thread.Sleep loop and the validate loop become await Task.Delay; bundle-file streaming is async (GetChunkAsync + async append-write).
  • RestDispatcherDispatchDispatchAsync; HttpContext.RequestAborted is threaded end-to-end through body read, dispatch, and response write.

Best-practice details: OperationCanceledException from a client disconnect is caught-and-rethrown before the broad catch blocks so a disconnect can't be mapped to a bogus FAIL/RESET or corrupt the resumable transaction state. Async FileStreams use FileOptions.Asynchronous. No ConfigureAwait(false) was sprinkled into app code (unnecessary in ASP.NET Core). Per scope, the tiny (<1KB) per-transaction metadata JSON I/O was intentionally left synchronous, and no DI was introduced.

Behavior is preserved: the X-HgR-* wire contract, explicit Content-Length, status-code mapping, and the client-visible poll timing (5×1s / 7×2s) are all unchanged — only the blocking mechanism differs.

Test plan

  • dotnet build HgResume.slnx — clean, 0 warnings / 0 errors.
  • Grep confirms no Thread.Sleep, sync WaitForExit(), or .GetAwaiter().GetResult() remain in src/ (the residual .Result reads are post-await Task.WhenAll, i.e. non-blocking).
  • All 48 HgResume.HttpTests pass against the podman container (push/pull resume flows, InProgress polling via the now-Task.Delay loop, unrelated-repo detection, offset/SOW handling, and the manyRevsHgRepo IsValidBase pagination cases).

This change is Reviewable

hahn-kev and others added 6 commits August 4, 2026 10:52
Convert the request-handling chain from synchronous/blocking to async all
the way down, removing thread-pool starvation on the hot paths.

- ProcessRunner.RunSync -> RunAsync (WaitForExitAsync; drop sync-over-async)
- AsyncRunner: WaitForIsCompleteAsync (Task.Delay), async lock-file I/O;
  background-task design retained (its task stays on CancellationToken.None
  so it survives the originating request)
- HgRunner / HgResumeApi: async Task methods throughout; the up-to-14s pull
  Thread.Sleep loop and 5s validate loop become await Task.Delay; async
  bundle-file streaming (GetChunkAsync, async append write)
- RestDispatcher: DispatchAsync; HttpContext.RequestAborted threaded end to
  end; OperationCanceledException on disconnect no longer mapped to a bogus
  FAIL/RESET

Behavior-preserving: the X-HgR-* wire contract, explicit Content-Length,
status codes, and poll timing (5x1s / 7x2s) are unchanged. Tiny metadata
JSON I/O intentionally left synchronous. All 46 HttpTests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse the two-job Docker workflow into a single build -> test -> push job
following Docker's test-before-push pattern: build+load the amd64 image, run
the tests against it, then build+push the multi-arch image reusing the amd64
layers from the builder cache (only arm64 is built for the push). Login/push
gating for fork PRs is unchanged. Action versions bumped to latest majors.

Also run HgResume.SendReceiveTests (real Chorus resumable client) in CI, not
just HttpTests. They were never OS-guarded in code; the only blocker was the
csproj force-copying a checked-in Windows hg.exe bundle on every platform.
Stage that bundle on Windows only so on Linux the cross-platform hg from
SIL.Chorus.Mercurial is used instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A job-wide VERSION env var is imported by MSBuild as the $(Version) property,
which made `dotnet test` fail with "'v<date>' is not a valid version string"
once build/test/push shared one job. Emit the date tag as a step output and
reference it from metadata-action instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only the Windows hg.exe bundle (Mercurial\) should be Windows-only. The
MercurialExtensions\fixutf8 directory is cross-platform Python that Chorus
requires on every OS (HgRepository.CheckMercurialIni), so gating it broke the
Linux send/receive run with "Could not find the directory
MercurialExtensions/fixutf8". Stage it unconditionally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MSBuild on Linux does not normalize backslashes in Include globs, so
"MercurialExtensions\**" matched nothing on the runner and Chorus couldn't
find MercurialExtensions/fixutf8. Forward-slash globs match on both Windows
and Linux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The <Content Include> glob for the package-provided Mercurial/ and
MercurialExtensions/ dirs is evaluated before SIL.Chorus.Mercurial's CopyFiles
target drops them into the project dir, so on a clean build nothing reached
the output dir and Chorus failed with "Could not find MercurialExtensions/
fixutf8". Replace the includes with an AfterTargets=Build target whose globs
run after CopyFiles. MercurialExtensions (from runtimes/any) is staged on all
platforms; the win hg (Mercurial/) only on Windows, since on Linux NuGet
already copies the linux-x64 hg to output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@megahirt

megahirt commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This is a beautiful example of using AI to squash some tech debt - well done!

@hahn-kev

hahn-kev commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Yeah it's pretty sweet. I'm hoping next to point a profiler at the full stack and see if we can make it super fast.

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