diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ab59089..30cd940 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -13,27 +13,57 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: - test: - name: Build image and run HTTP-level tests + build-test-push: + name: Build image, run tests, then push runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 + + # A step output, not an env var: a job-wide `VERSION` env var is imported by MSBuild as the + # $(Version) property and breaks `dotnet test` ('v' is not a valid version string). + - name: Set version tag + id: version + run: echo "value=v$(date --rfc-3339=date)" >> "${GITHUB_OUTPUT}" + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # For a pull request this produces the tag `pr-`, so the image can be pulled and + # tested in a real environment: `docker pull ghcr.io/:pr-`. + tags: | + type=ref,event=branch + type=ref,event=pr + type=raw,value=${{ steps.version.outputs.value }},enable=${{ github.event_name != 'pull_request' }} - - name: Build image for tests (amd64, loaded into docker) - uses: docker/build-push-action@v6 + # Build the amd64 image and load it into the local Docker daemon so the test fixtures can run it + # as a container (a multi-arch image can't be loaded/run directly). The multi-arch push step below + # reuses this build's cache, so amd64 is not compiled twice. + - name: Build and export to Docker (amd64) + uses: docker/build-push-action@v7 with: context: csharp file: csharp/Dockerfile load: true platforms: linux/amd64 tags: hgresume-csharp:test + cache-to: type=gha,mode=max + cache-from: type=gha - name: Set up .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' @@ -48,51 +78,28 @@ jobs: HGRESUME_PORT: '8034' run: dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal" - build: - name: Build and push docker image to GitHub packages - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set Version - run: | - echo "VERSION=v$(date --rfc-3339=date)" >> ${GITHUB_ENV} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Run send/receive tests (real Chorus client) against the image + working-directory: csharp + env: + HGRESUME_PODMAN: docker + HGRESUME_IMAGE: hgresume-csharp:test + HGRESUME_SKIP_BUILD: '1' + HGRESUME_PORT: '8041' + run: dotnet test test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj --logger "console;verbosity=normal" - name: Log in to the Container registry # Fork PRs get a read-only GITHUB_TOKEN; skip login/push so the job still builds without a 403. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # For a pull request this produces the tag `pr-`, so the image can be pulled and - # tested in a real environment: `docker pull ghcr.io/:pr-`. - tags: | - type=ref,event=branch - type=ref,event=pr - type=raw,value=${{ env.VERSION }},enable=${{ github.event_name != 'pull_request' }} - + # Multi-arch build + push. amd64 layers come from the cache populated above (no recompile); only + # arm64 is built here. Push is gated the same way as login. - name: Build and push Docker image (C#) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: csharp file: csharp/Dockerfile @@ -100,3 +107,4 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha diff --git a/csharp/src/HgResume.Api/AsyncRunner.cs b/csharp/src/HgResume.Api/AsyncRunner.cs index fcd0cea..c45d905 100644 --- a/csharp/src/HgResume.Api/AsyncRunner.cs +++ b/csharp/src/HgResume.Api/AsyncRunner.cs @@ -70,12 +70,15 @@ public void Run(string workingDir, string program, params string[] args) sb.Append($"\nCommand exited with non-zero status {proc.ExitCode}\n"); } sb.Append($"\n{CompletedMarker}: done\n"); - File.WriteAllText(lockFile, sb.ToString()); + // CancellationToken.None: this background write must survive the originating request. + await File.WriteAllTextAsync(lockFile, sb.ToString(), CancellationToken.None).ConfigureAwait(false); } catch (Exception e) { // Ensure a completion marker is always written so pollers do not hang forever. - File.WriteAllText(lockFile, $"AsyncRunner error: {e.Message}\n{CompletedMarker}: error\n"); + await File.WriteAllTextAsync(lockFile, + $"AsyncRunner error: {e.Message}\n{CompletedMarker}: error\n", CancellationToken.None) + .ConfigureAwait(false); } finally { @@ -95,22 +98,22 @@ public void Run(string workingDir, string program, params string[] args) public bool IsRunning() => File.Exists(_lockFile); - public bool IsComplete() + public async Task IsCompleteAsync(CancellationToken ct = default) { if (!File.Exists(_lockFile)) { throw new AsyncRunnerException($"Lock file '{_lockFile}' not found, process is not running"); } - return ReadLockFile().Contains(CompletedMarker); + return (await ReadLockFileAsync(ct)).Contains(CompletedMarker); } - public string GetOutput() + public async Task GetOutputAsync(CancellationToken ct = default) { - if (!IsComplete()) + if (!await IsCompleteAsync(ct)) { throw new AsyncRunnerException($"Command on '{_lockFile}' not yet complete."); } - return ReadLockFile(); + return await ReadLockFileAsync(ct); } public void CleanUp() @@ -119,21 +122,22 @@ public void CleanUp() } /// Waits up to ~5s for the runner to complete. Mirrors PHP waitForIsComplete(). - public bool WaitForIsComplete() + public async Task WaitForIsCompleteAsync(CancellationToken ct = default) { for (int i = 0; i < 5; i++) { - if (IsComplete()) return true; - Thread.Sleep(1000); + if (await IsCompleteAsync(ct)) return true; + await Task.Delay(1000, ct); } return false; } - private string ReadLockFile() + private async Task ReadLockFileAsync(CancellationToken ct = default) { // Share read/write so we never contend with the background writer. - using var fs = new FileStream(_lockFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await using var fs = new FileStream(_lockFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, + bufferSize: 4096, FileOptions.Asynchronous); using var reader = new StreamReader(fs, Encoding.UTF8); - return reader.ReadToEnd(); + return await reader.ReadToEndAsync(ct); } } diff --git a/csharp/src/HgResume.Api/HgResumeApi.cs b/csharp/src/HgResume.Api/HgResumeApi.cs index 2fbf80e..9abc876 100644 --- a/csharp/src/HgResume.Api/HgResumeApi.cs +++ b/csharp/src/HgResume.Api/HgResumeApi.cs @@ -18,9 +18,10 @@ public HgResumeApi(ApiConfig config) // ---- push ----------------------------------------------------------------------------------- - public HgResumeResponse PushBundleChunk(string repoId, int bundleSize, int offset, byte[] data, string transId) + public async Task PushBundleChunkAsync(string repoId, int bundleSize, int offset, + byte[] data, string transId, CancellationToken ct = default) { - var availability = IsAvailable(); + var availability = await IsAvailableAsync(ct); if (availability.Code == HgResumeResponse.NOTAVAILABLE) { return availability; @@ -80,9 +81,10 @@ public HgResumeResponse PushBundleChunk(string repoId, int bundleSize, int offse } // write chunk data to bundle file (chunks arrive in order so offset == current length) - using (var fs = new FileStream(bundle.BundleFileName, FileMode.Append, FileAccess.Write)) + await using (var fs = new FileStream(bundle.BundleFileName, FileMode.Append, + FileAccess.Write, FileShare.None, bufferSize: 4096, FileOptions.Asynchronous)) { - fs.Write(data, 0, data.Length); + await fs.WriteAsync(data, ct); } int newSow = offset + dataSize; @@ -102,13 +104,14 @@ public HgResumeResponse PushBundleChunk(string repoId, int bundleSize, int offse case BundleHelper.State_Validating: case BundleHelper.State_Unbundle: - return CompletePushBundle(bundle, hg, transId, bundleSize); + return await CompletePushBundleAsync(bundle, hg, transId, bundleSize, ct); } return new HgResumeResponse(HgResumeResponse.FAIL); // unreachable, mirrors PHP returning null } - private HgResumeResponse CompletePushBundle(BundleHelper bundle, HgRunner hg, string transId, int bundleSize) + private async Task CompletePushBundleAsync(BundleHelper bundle, HgRunner hg, + string transId, int bundleSize, CancellationToken ct) { try { @@ -121,7 +124,7 @@ private HgResumeResponse CompletePushBundle(BundleHelper bundle, HgRunner hg, st goto case BundleHelper.State_Validating; case BundleHelper.State_Validating: - if (hg.FinishValidating(bundleFilePath)) + if (await hg.FinishValidatingAsync(bundleFilePath, ct)) { bundle.State = BundleHelper.State_Unbundle; hg.Unbundle(bundleFilePath); @@ -131,9 +134,9 @@ private HgResumeResponse CompletePushBundle(BundleHelper bundle, HgRunner hg, st case BundleHelper.State_Unbundle: var asyncRunner = new AsyncRunner(bundleFilePath); - if (asyncRunner.WaitForIsComplete()) + if (await asyncRunner.WaitForIsCompleteAsync(ct)) { - if (BundleHelper.BundleOutputHasErrors(asyncRunner.GetOutput())) + if (BundleHelper.BundleOutputHasErrors(await asyncRunner.GetOutputAsync(ct))) { return new HgResumeResponse(HgResumeResponse.RESET, new Dictionary { @@ -151,6 +154,12 @@ private HgResumeResponse CompletePushBundle(BundleHelper bundle, HgRunner hg, st } return new HgResumeResponse(HgResumeResponse.FAIL); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Client disconnected: don't reset the transaction's offset or fabricate an error + // response — let the aborted request unwind so the resumable state stays intact. + throw; + } catch (UnrelatedRepoException e) { bundle.SetOffset(0); @@ -173,16 +182,17 @@ private HgResumeResponse CompletePushBundle(BundleHelper bundle, HgRunner hg, st // ---- pull ----------------------------------------------------------------------------------- - public HgResumeResponse PullBundleChunk(string repoId, IReadOnlyList baseHashes, int offset, - int chunkSize, string transId) - => PullBundleChunkInternal(repoId, baseHashes, offset, chunkSize, transId, false); + public Task PullBundleChunkAsync(string repoId, IReadOnlyList baseHashes, + int offset, int chunkSize, string transId, CancellationToken ct = default) + => PullBundleChunkInternalAsync(repoId, baseHashes, offset, chunkSize, transId, false, ct); - public HgResumeResponse PullBundleChunkInternal(string repoId, IReadOnlyList baseHashes, int offset, - int chunkSize, string transId, bool waitForBundleToFinish) + public async Task PullBundleChunkInternalAsync(string repoId, + IReadOnlyList baseHashes, int offset, int chunkSize, string transId, + bool waitForBundleToFinish, CancellationToken ct = default) { try { - var availability = IsAvailable(); + var availability = await IsAvailableAsync(ct); if (availability.Code == HgResumeResponse.NOTAVAILABLE) { return availability; @@ -199,14 +209,14 @@ public HgResumeResponse PullBundleChunkInternal(string repoId, IReadOnlyList h, StringComparer.Ordinal).ToList(); - var branchTips = hg.GetBranchTips(); + var branchTips = await hg.GetBranchTipsAsync(ct); branchTips.Sort(StringComparer.Ordinal); if (branchTips.Count == 0) { @@ -240,9 +250,9 @@ public HgResumeResponse PullBundleChunkInternal(string repoId, IReadOnlyList { - ["Error"] = Truncate(asyncRunner.GetOutput()), + ["Error"] = Truncate(bundleOutput), }); } bundle.State = BundleHelper.State_Downloading; @@ -266,7 +277,7 @@ public HgResumeResponse PullBundleChunkInternal(string repoId, IReadOnlyList { ["bundleSize"] = new FileInfo(bundleFilename).Length.ToString(), @@ -276,13 +287,13 @@ public HgResumeResponse PullBundleChunkInternal(string repoId, IReadOnlyList { @@ -301,6 +312,11 @@ public HgResumeResponse PullBundleChunkInternal(string repoId, IReadOnlyList GetChunkAsync(string filename, int chunkSize, int offset, + CancellationToken ct) { var fi = new FileInfo(filename); if (!fi.Exists || offset >= fi.Length) { return Array.Empty(); } - using var fs = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await using var fs = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, + bufferSize: 4096, FileOptions.Asynchronous); fs.Seek(offset, SeekOrigin.Begin); int toRead = (int)Math.Min(chunkSize, fs.Length - offset); var buffer = new byte[toRead]; int read = 0; while (read < toRead) { - int n = fs.Read(buffer, read, toRead - read); + int n = await fs.ReadAsync(buffer.AsMemory(read, toRead - read), ct); if (n == 0) break; read += n; } @@ -337,9 +355,10 @@ private static byte[] GetChunk(string filename, int chunkSize, int offset) // ---- misc ----------------------------------------------------------------------------------- - public HgResumeResponse GetRevisions(string repoId, int offset, int quantity) + public async Task GetRevisionsAsync(string repoId, int offset, int quantity, + CancellationToken ct = default) { - var availability = IsAvailable(); + var availability = await IsAvailableAsync(ct); if (availability.Code == HgResumeResponse.NOTAVAILABLE) { return availability; @@ -352,25 +371,29 @@ public HgResumeResponse GetRevisions(string repoId, int offset, int quantity) return new HgResumeResponse(HgResumeResponse.UNKNOWNID); } var hg = new HgRunner(repoPath); - var revisionList = hg.GetRevisions(offset, quantity); + var revisionList = await hg.GetRevisionsAsync(offset, quantity, ct); return new HgResumeResponse(HgResumeResponse.SUCCESS, new Dictionary(), string.Join("|", revisionList)); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception e) { return Fail(Truncate(e.Message)); } } - public HgResumeResponse FinishPushBundle(string transId) + public Task FinishPushBundleAsync(string transId) { var bundle = new BundleHelper(_config, transId); - return bundle.CleanUp() + return Task.FromResult(bundle.CleanUp() ? new HgResumeResponse(HgResumeResponse.SUCCESS) - : new HgResumeResponse(HgResumeResponse.FAIL); + : new HgResumeResponse(HgResumeResponse.FAIL)); } - public HgResumeResponse FinishPullBundle(string transId) + public async Task FinishPullBundleAsync(string transId, CancellationToken ct = default) { var bundle = new BundleHelper(_config, transId); if (bundle.HasProp("tip") && bundle.HasProp("repoId")) @@ -380,7 +403,7 @@ public HgResumeResponse FinishPullBundle(string transId) { var hg = new HgRunner(repoPath); // check that the repo has not been updated since the pull started - if (bundle.GetProp("tip") != hg.GetTip()) + if (bundle.GetProp("tip") != await hg.GetTipAsync(ct)) { bundle.CleanUp(); return new HgResumeResponse(HgResumeResponse.RESET); @@ -392,13 +415,13 @@ public HgResumeResponse FinishPullBundle(string transId) : new HgResumeResponse(HgResumeResponse.FAIL); } - public HgResumeResponse IsAvailable() + public async Task IsAvailableAsync(CancellationToken ct = default) { if (IsAvailableAsBool()) { return new HgResumeResponse(HgResumeResponse.SUCCESS); } - string message = File.ReadAllText(_config.MaintenanceFilePath, Encoding.UTF8); + string message = await File.ReadAllTextAsync(_config.MaintenanceFilePath, Encoding.UTF8, ct); return new HgResumeResponse(HgResumeResponse.NOTAVAILABLE, new Dictionary(), message); } diff --git a/csharp/src/HgResume.Api/HgRunner.cs b/csharp/src/HgResume.Api/HgRunner.cs index c3c8f44..33c7c5d 100644 --- a/csharp/src/HgResume.Api/HgRunner.cs +++ b/csharp/src/HgResume.Api/HgRunner.cs @@ -37,12 +37,12 @@ public void StartValidating(string filepath) } /// true if validation finished, otherwise false (still running) - public bool FinishValidating(string filepath) + public async Task FinishValidatingAsync(string filepath, CancellationToken ct = default) { var asyncRunner = GetValidationRunner(filepath); - if (asyncRunner.WaitForIsComplete()) + if (await asyncRunner.WaitForIsCompleteAsync(ct)) { - string output = asyncRunner.GetOutput(); + string output = await asyncRunner.GetOutputAsync(ct); if (UnknownParent.IsMatch(output)) { throw new UnrelatedRepoException("Project is unrelated! (unrelated bundle pushed to repo)"); @@ -114,10 +114,11 @@ public AsyncRunner MakeBundle(IReadOnlyList baseHashes, string bundleFil return asyncRunner; } - public AsyncRunner MakeBundleAndWaitUntilFinished(IReadOnlyList baseHashes, string bundleFilePath) + public async Task MakeBundleAndWaitUntilFinishedAsync(IReadOnlyList baseHashes, + string bundleFilePath, CancellationToken ct = default) { var asyncRunner = MakeBundle(baseHashes, bundleFilePath); - if (!asyncRunner.WaitForIsComplete()) + if (!await asyncRunner.WaitForIsCompleteAsync(ct)) { throw new HgException("Error: make bundle failed to complete"); } @@ -127,17 +128,17 @@ public AsyncRunner MakeBundleAndWaitUntilFinished(IReadOnlyList baseHash // ---- revision inspection -------------------------------------------------------------------- /// a baseHash (without branch information) - public string GetTip() + public async Task GetTipAsync(CancellationToken ct = default) { - var revisionArray = GetRevisions(0, 1); + var revisionArray = await GetRevisionsAsync(0, 1, ct); string first = revisionArray[0]; int colon = first.IndexOf(':'); return colon >= 0 ? first.Substring(0, colon) : first; } - public List GetBranchTips() + public async Task> GetBranchTipsAsync(CancellationToken ct = default) { - var (branches, _) = ProcessRunner.RunSync(RepoPath, "hg", "branches"); + var (branches, _) = await ProcessRunner.RunAsync(RepoPath, "hg", ["branches"], ct); var revisionArray = new List(); foreach (var branch in branches) { @@ -151,7 +152,7 @@ public List GetBranchTips() int space = branch.IndexOf(' '); branchName = space >= 0 ? branch.Substring(0, space) : branch; } - revisionArray.AddRange(GetRevisionsInternal(0, 1, branchName)); + revisionArray.AddRange(await GetRevisionsInternalAsync(0, 1, branchName, ct)); } var revisions = new List(); foreach (var hashAndBranch in revisionArray) @@ -163,9 +164,11 @@ public List GetBranchTips() } /// Returns "hash:branch" pairs, e.g. 'fb7a8f23394d:default'. - public List GetRevisions(int offset, int quantity) => GetRevisionsInternal(offset, quantity, null); + public Task> GetRevisionsAsync(int offset, int quantity, CancellationToken ct = default) + => GetRevisionsInternalAsync(offset, quantity, null, ct); - private List GetRevisionsInternal(int offset, int quantity, string? branch) + private async Task> GetRevisionsInternalAsync(int offset, int quantity, string? branch, + CancellationToken ct = default) { if (quantity < 1) { @@ -176,10 +179,11 @@ private List GetRevisionsInternal(int offset, int quantity, string? bran ? new[] { "log", "--template", "{node|short}:{branches}\n" } : new[] { "log", "-b", branch, "--template", "{node|short}:{branches}\n" }; - var (output, _) = ProcessRunner.RunSync(RepoPath, "hg", args); + var (output, _) = await ProcessRunner.RunAsync(RepoPath, "hg", args, ct); if (output.Count == 0) { - var (tip, _) = ProcessRunner.RunSync(RepoPath, "hg", "tip", "--template", "{rev}:{branches}\n"); + var (tip, _) = await ProcessRunner.RunAsync(RepoPath, "hg", + ["tip", "--template", "{rev}:{branches}\n"], ct); if (tip.Count == 1 && tip[0].StartsWith("-1")) { // Empty repo (hg init, zero changesets). At offset 0 we emit '0:' (from @@ -198,7 +202,7 @@ private List GetRevisionsInternal(int offset, int quantity, string? bran return output.Skip(offset).Take(quantity).ToList(); } - public bool IsValidBase(IReadOnlyList hashes) + public async Task IsValidBaseAsync(IReadOnlyList hashes, CancellationToken ct = default) { if (hashes.Count == 1 && hashes[0] == "0") { @@ -209,7 +213,7 @@ public bool IsValidBase(IReadOnlyList hashes) int i = 0; while (foundHash < hashes.Count) { - var revisions = GetRevisions(i, q); + var revisions = await GetRevisionsAsync(i, q, ct); if (revisions.Count == 0) { return false; // paged past the last revision without matching every hash diff --git a/csharp/src/HgResume.Api/ProcessRunner.cs b/csharp/src/HgResume.Api/ProcessRunner.cs index 492029e..d1fadbc 100644 --- a/csharp/src/HgResume.Api/ProcessRunner.cs +++ b/csharp/src/HgResume.Api/ProcessRunner.cs @@ -10,11 +10,11 @@ namespace HgResume.Api; public static class ProcessRunner { /// - /// Runs a command synchronously (mirrors PHP exec()): returns stdout split into lines with the - /// trailing empty line removed, plus the exit code. stderr is discarded (PHP exec captured stdout). + /// Runs a command (mirrors PHP exec()): returns stdout split into lines with the trailing empty + /// line removed, plus the exit code. stderr is discarded (PHP exec captured stdout). /// - public static (List Lines, int ExitCode) RunSync(string workingDir, string program, - params string[] args) + public static async Task<(List Lines, int ExitCode)> RunAsync(string workingDir, + string program, string[] args, CancellationToken ct = default) { var psi = new ProcessStartInfo { @@ -29,11 +29,11 @@ public static (List Lines, int ExitCode) RunSync(string workingDir, stri using var proc = Process.Start(psi) ?? throw new HgException($"failed to start process '{program}'"); // Read both streams to avoid pipe-buffer deadlock. - var stdoutTask = proc.StandardOutput.ReadToEndAsync(); - var stderrTask = proc.StandardError.ReadToEndAsync(); - proc.WaitForExit(); - string stdout = stdoutTask.GetAwaiter().GetResult(); - _ = stderrTask.GetAwaiter().GetResult(); + var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); + var stderrTask = proc.StandardError.ReadToEndAsync(ct); + await Task.WhenAll(stdoutTask, stderrTask); + await proc.WaitForExitAsync(ct); + string stdout = stdoutTask.Result; var lines = stdout.Replace("\r\n", "\n").Split('\n').ToList(); // PHP exec() drops the trailing newline / empty final element. diff --git a/csharp/src/HgResume.Api/RestDispatcher.cs b/csharp/src/HgResume.Api/RestDispatcher.cs index 2c72668..79042c1 100644 --- a/csharp/src/HgResume.Api/RestDispatcher.cs +++ b/csharp/src/HgResume.Api/RestDispatcher.cs @@ -21,8 +21,9 @@ public RestDispatcher(ApiConfig config, HgResumeApi api) public async Task HandleAsync(HttpContext context) { + var ct = context.RequestAborted; string methodName = LastPathSegment(context.Request.Path.Value ?? ""); - byte[] body = await ReadBodyAsync(context.Request); + byte[] body = await ReadBodyAsync(context.Request, ct); var query = context.Request.Query; // Always answer with the X-HgR-* contract. PHP RestServer::serverError did the same for @@ -31,59 +32,68 @@ public async Task HandleAsync(HttpContext context) HgResumeResponse response; try { - response = Dispatch(methodName, query, body); + response = await DispatchAsync(methodName, query, body, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Client went away — no point building or sending an X-HgR-* response. + return; } catch (Exception e) { response = ServerError(Truncate(e.Message)); } - await SendResponseAsync(context, response); + await SendResponseAsync(context, response, ct); } - private HgResumeResponse Dispatch(string methodName, IQueryCollection query, byte[] body) + private Task DispatchAsync(string methodName, IQueryCollection query, byte[] body, + CancellationToken ct) { switch (methodName) { case "pushBundleChunk": // PHP maps data <- postData (always present from the body); remaining params are required. RequireParams(methodName, query, "repoId", "bundleSize", "offset", "transId"); - return _api.PushBundleChunk( + return _api.PushBundleChunkAsync( Str(query, "repoId"), PhpInt(Str(query, "bundleSize")), PhpInt(Str(query, "offset")), body, - Str(query, "transId")); + Str(query, "transId"), + ct); case "pullBundleChunk": RequireParams(methodName, query, "repoId", "baseHashes", "offset", "chunkSize", "transId"); - return _api.PullBundleChunk( + return _api.PullBundleChunkAsync( Str(query, "repoId"), BaseHashes(query), PhpInt(Str(query, "offset")), PhpInt(Str(query, "chunkSize")), - Str(query, "transId")); + Str(query, "transId"), + ct); case "getRevisions": RequireParams(methodName, query, "repoId", "offset", "quantity"); - return _api.GetRevisions( + return _api.GetRevisionsAsync( Str(query, "repoId"), PhpInt(Str(query, "offset")), - PhpInt(Str(query, "quantity"))); + PhpInt(Str(query, "quantity")), + ct); case "finishPushBundle": RequireParams(methodName, query, "transId"); - return _api.FinishPushBundle(Str(query, "transId")); + return _api.FinishPushBundleAsync(Str(query, "transId")); case "finishPullBundle": RequireParams(methodName, query, "transId"); - return _api.FinishPullBundle(Str(query, "transId")); + return _api.FinishPullBundleAsync(Str(query, "transId"), ct); case "isAvailable": - return _api.IsAvailable(); + return _api.IsAvailableAsync(ct); default: - return ServerError($"Unknown method '{methodName}'"); + return Task.FromResult(ServerError($"Unknown method '{methodName}'")); } } @@ -121,7 +131,7 @@ private static HgResumeResponse ServerError(string msg) => private static string Truncate(string s) => s.Length > 1000 ? s.Substring(0, 1000) : s; - private async Task SendResponseAsync(HttpContext context, HgResumeResponse response) + private async Task SendResponseAsync(HttpContext context, HgResumeResponse response, CancellationToken ct) { var (httpCode, hgrStatus) = MapHgResponse(response.Code); @@ -142,7 +152,7 @@ private async Task SendResponseAsync(HttpContext context, HgResumeResponse respo // (it never falls back to chunked/Transfer-Encoding), so relying on Kestrel's default // chunked encoding silently breaks getRevisions and pullBundleChunk for the real client. res.ContentLength = response.Content.Length; - await res.Body.WriteAsync(response.Content); + await res.Body.WriteAsync(response.Content, ct); } } @@ -168,10 +178,10 @@ private static string LastPathSegment(string path) return segments.Length == 0 ? "" : segments[^1]; } - private static async Task ReadBodyAsync(HttpRequest request) + private static async Task ReadBodyAsync(HttpRequest request, CancellationToken ct) { using var ms = new MemoryStream(); - await request.Body.CopyToAsync(ms); + await request.Body.CopyToAsync(ms, ct); return ms.ToArray(); } diff --git a/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj b/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj index 461f7ab..7d85425 100644 --- a/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj +++ b/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj @@ -7,8 +7,9 @@ dependencies replaced by creating repos directly in the container. We use the Chorus hg repo + transport directly rather than the full LfMergeBridge flow (its FixFwData fixup needs the whole FieldWorks stack and never touches the server), so we only depend on LibChorus + the bundled - Mercurial. Windows-only (LibChorus pulls in .NET Framework deps) and requires podman + the built - image, so this project is intentionally NOT part of the Linux CI test job. + Mercurial. Requires a container runtime (docker/podman) + the built image. Runs in Linux CI: the + checked-in Windows Mercurial bundle below is staged on Windows only, so on Linux the cross-platform + hg that SIL.Chorus.Mercurial ships is used instead. --> net10.0 @@ -35,9 +36,32 @@ - - + + + + <_HgExtDrop Include="$(MSBuildProjectDirectory)/MercurialExtensions/**" /> + <_HgBinDrop Include="$(MSBuildProjectDirectory)/Mercurial/**" + Condition="$([MSBuild]::IsOSPlatform('Windows'))" /> + + + + +