Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 101 additions & 32 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,110 @@ env:
IMAGE_NAME: ${{ github.repository }}

jobs:
build-test-push:
name: Build image, run tests, then push
# Build the amd64 image once and hand it to every test leg as an artifact (a multi-arch image can't
# be loaded/run directly). The push job reuses this build's cache, so amd64 is not compiled twice.
build:
name: Build image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Build amd64 image
uses: docker/build-push-action@v7
with:
context: csharp
file: csharp/Dockerfile
platforms: linux/amd64
tags: hgresume-csharp:test
outputs: type=docker,dest=${{ runner.temp }}/hgresume-image.tar
cache-to: type=gha,mode=max
cache-from: type=gha

- name: Upload image
uses: actions/upload-artifact@v7
with:
name: hgresume-image
path: ${{ runner.temp }}/hgresume-image.tar
retention-days: 1

# Run the integration tests against the image once per Chorus client (see ChorusClient in
# HgResume.IntegrationTests.csproj). The HTTP-level tests don't depend on the client, so only the
# pinned leg runs them; the other legs run just the tests that drive the real Chorus client.
test:
name: Integration tests (${{ matrix.client }} Chorus)
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- client: pinned
filter: ''
- client: latest
filter: FullyQualifiedName~SendReceiveTests|FullyQualifiedName~VerifyHgWorking
# Chorus this old drops the port from the repo URL, so the server has to be on port 80.
- client: hg3
filter: FullyQualifiedName~SendReceiveTests|FullyQualifiedName~VerifyHgWorking
host-port: '80'
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Download image
uses: actions/download-artifact@v8
with:
name: hgresume-image
path: ${{ runner.temp }}

- name: Load image
run: docker load --input "${{ runner.temp }}/hgresume-image.tar"

- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'

# The Mercurial 3 bundle's Linux hg is a #!/usr/bin/python2 script, and ubuntu-latest has no
# Python 2 (same setup as LexBox's integration-test.yaml).
- name: Set up Python 2 for Mercurial 3
id: python
if: matrix.client == 'hg3'
uses: MatteoH2O1999/setup-python@v6
with:
python-version: '2.7.18'
cache-build: true
allow-build: allow

- name: Link python2
if: matrix.client == 'hg3'
run: sudo ln -s "${{ steps.python.outputs.python-path }}" /usr/bin/python2

- name: Run integration tests against the image
working-directory: csharp
env:
# The test fixture drives the pre-built image itself via Testcontainers (against the
# runner's local Docker daemon), so we skip the fixture's own build step.
HGRESUME_IMAGE: hgresume-csharp:test
HGRESUME_SKIP_BUILD: '1'
HGRESUME_HOST_PORT: ${{ matrix.host-port }}
TEST_FILTER: ${{ matrix.filter }}
run: |
args=(test/HgResume.IntegrationTests/HgResume.IntegrationTests.csproj
"-p:ChorusClient=${{ matrix.client }}" --logger "console;verbosity=normal")
if [ -n "$TEST_FILTER" ]; then args+=(--filter "$TEST_FILTER"); fi
dotnet test "${args[@]}"

push:
name: Build and push multi-arch image
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -47,34 +144,6 @@ jobs:
type=ref,event=pr
type=raw,value=${{ steps.version.outputs.value }},enable=${{ github.event_name != 'pull_request' }}

# 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@v6
with:
dotnet-version: '10.0.x'

- name: Run integration tests (HTTP-level + real Chorus client) against the image
working-directory: csharp
env:
# The test fixture drives the pre-built image itself via Testcontainers (against the
# runner's local Docker daemon), so we skip the fixture's own build step.
HGRESUME_IMAGE: hgresume-csharp:test
HGRESUME_SKIP_BUILD: '1'
run: dotnet test test/HgResume.IntegrationTests/HgResume.IntegrationTests.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
Expand All @@ -84,7 +153,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Multi-arch build + push. amd64 layers come from the cache populated above (no recompile); only
# Multi-arch build + push. amd64 layers come from the build job's cache (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@v7
Expand Down
3 changes: 0 additions & 3 deletions csharp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,3 @@ obj/
.idea/
.vs/

# Bundled Mercurial dropped into the integration test project by SIL.Chorus.Mercurial at build time
test/HgResume.IntegrationTests/Mercurial/
test/HgResume.IntegrationTests/MercurialExtensions/
40 changes: 14 additions & 26 deletions csharp/src/HgResume.Api/HgResumeApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,14 @@ public async Task<HgResumeResponse> PullBundleChunkInternalAsync(string repoId,
{
if (CanGetChunkBelowBundleSize(bundleFilename, chunkSize, offset))
{
byte[] data = await GetChunkAsync(bundleFilename, chunkSize, offset, ct);
var data = GetChunkRegion(bundleFilename, chunkSize, offset);
response.Values = new Dictionary<string, string>
{
["bundleSize"] = new FileInfo(bundleFilename).Length.ToString(),
["chunkSize"] = data.Length.ToString(),
["chunkSize"] = data.Count.ToString(),
["transId"] = transId,
};
response.Content = data;
response.BodyFile = data;
return response; // break out of loop and switch (PHP: break 2)
}
await Task.Delay(2000, ct);
Expand All @@ -293,15 +293,15 @@ public async Task<HgResumeResponse> PullBundleChunkInternalAsync(string repoId,
break;

case BundleHelper.State_Downloading:
byte[] chunk = await GetChunkAsync(bundleFilename, chunkSize, offset, ct);
var chunk = GetChunkRegion(bundleFilename, chunkSize, offset);
long size = new FileInfo(bundleFilename).Length;
response.Values = new Dictionary<string, string>
{
["bundleSize"] = size.ToString(),
["chunkSize"] = chunk.Length.ToString(),
["chunkSize"] = chunk.Count.ToString(),
["transId"] = transId,
};
response.Content = chunk;
response.BodyFile = chunk;
if (offset > size)
{
throw new ValidationException(
Expand Down Expand Up @@ -329,28 +329,16 @@ private static bool CanGetChunkBelowBundleSize(string filename, int chunkSize, i
return fi.Exists && offset + chunkSize < fi.Length;
}

private static async Task<byte[]> GetChunkAsync(string filename, int chunkSize, int offset,
CancellationToken ct)
/// <summary>
/// The part of the bundle file to send for a chunk request: from offset, the smaller of chunkSize and
/// what the file holds past the offset right now (empty if nothing). It is sent straight from the
/// file (see RestDispatcher), so the chunk is never held in memory, whatever its size.
/// </summary>
private static HgResumeResponse.FileRegion GetChunkRegion(string filename, int chunkSize, int offset)
{
var fi = new FileInfo(filename);
if (!fi.Exists || offset >= fi.Length)
{
return Array.Empty<byte>();
}
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 = await fs.ReadAsync(buffer.AsMemory(read, toRead - read), ct);
if (n == 0) break;
read += n;
}
if (read < toRead) Array.Resize(ref buffer, read);
return buffer;
long available = fi.Exists ? fi.Length - offset : 0;
return new HgResumeResponse.FileRegion(filename, offset, Math.Max(0, Math.Min(chunkSize, available)));
}

// ---- misc -----------------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions csharp/src/HgResume.Api/HgResumeResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ public sealed class HgResumeResponse
public byte[] Content { get; set; }
public int Version { get; set; }

/// <summary>
/// A region of a file to send as the body instead of <see cref="Content"/>, so a bundle chunk of any
/// size goes out without first being read into memory. Null when the body is <see cref="Content"/>.
/// </summary>
public FileRegion? BodyFile { get; set; }

public sealed record FileRegion(string Path, long Offset, long Count);

public HgResumeResponse(int code, Dictionary<string, string>? values = null, byte[]? content = null,
int version = ApiConfig.ApiVersion)
{
Expand Down
18 changes: 13 additions & 5 deletions csharp/src/HgResume.Api/RestDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,20 @@ private async Task SendResponseAsync(HttpContext context, HgResumeResponse respo
}
res.ContentType = "application/octet-stream";

if (response.Content.Length > 0)
// Set Content-Length explicitly (as the PHP RestServer did). The Chorus client reads the
// body with a hand-rolled reader that returns EMPTY when there is no Content-Length header
// (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.
if (response.BodyFile is { Count: > 0 } file)
{
// Sent straight from the file (sendfile where the platform has it), so a chunk as big as
// the whole bundle costs no memory for the whole time a slow client takes to download it.
// The file is opened with FileShare.ReadWrite, so a bundle hg is still writing can be read.
res.ContentLength = file.Count;
await res.SendFileAsync(file.Path, file.Offset, file.Count, ct);
}
else if (response.Content.Length > 0)
{
// Set Content-Length explicitly (as the PHP RestServer did). The Chorus client reads the
// body with a hand-rolled reader that returns EMPTY when there is no Content-Length header
// (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, ct);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,64 @@
needs the whole FieldWorks stack and never touches the server), so we only depend on LibChorus +
the bundled Mercurial. Drives the container via Testcontainers, which needs a Docker-API-compatible
endpoint (Docker Desktop/Engine work out of the box; podman needs its API socket exposed and
DOCKER_HOST pointed at it). 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.
DOCKER_HOST pointed at it). Runs on Windows and in Linux CI.
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- SIL.Chorus.Mercurial drops the bundled hg here at build time -->
<Mercurial4ChorusDestDir>$(MSBuildProjectDirectory)</Mercurial4ChorusDestDir>
<NoWarn>$(NoWarn);NU1701;NU1902;NU1903</NoWarn>
<!--
Which Chorus client the send/receive tests drive; CI runs every one (dotnet test -p:ChorusClient=hg3):
pinned the last LibChorus before the resumable transport moved from HttpWebRequest to
HttpClient (sillsdev/chorus#399), with Mercurial 6. The default.
latest the newest LibChorus, Mercurial and SIL.Core on NuGet.
hg3 an old LibChorus with Mercurial 3, as LexBox tested before it moved to Mercurial 6.
Its Linux hg runs under /usr/bin/python2.
Each client gets its own hg drop and output dir, so switching between them never mixes hg files.
-->
<ChorusClient Condition="'$(ChorusClient)' == ''">pinned</ChorusClient>
<OutputPath>bin/$(Configuration)/$(ChorusClient)/</OutputPath>
<!-- SIL.Chorus.Mercurial drops the bundled hg here at build time -->
<Mercurial4ChorusDestDir>$(MSBuildProjectDirectory)/obj/hg/$(ChorusClient)</Mercurial4ChorusDestDir>
</PropertyGroup>

<Choose>
<When Condition="'$(ChorusClient)' == 'pinned'">
<PropertyGroup>
<LibChorusVersion>6.0.0-beta0064</LibChorusVersion>
<ChorusMercurialVersion>6.5.1.43</ChorusMercurialVersion>
<SilCoreVersion>17.0.0</SilCoreVersion>
</PropertyGroup>
</When>
<When Condition="'$(ChorusClient)' == 'latest'">
<PropertyGroup>
<LibChorusVersion>*-*</LibChorusVersion>
<ChorusMercurialVersion>*</ChorusMercurialVersion>
<SilCoreVersion>*-*</SilCoreVersion>
</PropertyGroup>
</When>
<When Condition="'$(ChorusClient)' == 'hg3'">
<PropertyGroup>
<LibChorusVersion>6.0.0-beta0041</LibChorusVersion>
<ChorusMercurialVersion>3.0.3.11</ChorusMercurialVersion>
<SilCoreVersion>12.0.0</SilCoreVersion>
</PropertyGroup>
</When>
</Choose>

<Target Name="CheckChorusClient" BeforeTargets="Restore;CollectPackageReferences">
<Error Condition="'$(LibChorusVersion)' == ''"
Text="Unknown ChorusClient '$(ChorusClient)'; use pinned, latest or hg3." />
</Target>

<ItemGroup>
<PackageReference Include="Testcontainers" Version="4.14.0" />
<PackageReference Include="SIL.Chorus.LibChorus" Version="6.0.0-beta0064" />
<PackageReference Include="SIL.Chorus.Mercurial" Version="6.5.1.43" />
<PackageReference Include="SIL.Core" Version="17.0.0" />
<PackageReference Include="SIL.Chorus.LibChorus" Version="$(LibChorusVersion)" />
<PackageReference Include="SIL.Chorus.Mercurial" Version="$(ChorusMercurialVersion)" />
<PackageReference Include="SIL.Core" Version="$(SilCoreVersion)" />
<PackageReference Include="FluentAssertions" Version="7.0.0-alpha.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
Expand All @@ -47,27 +85,24 @@

<!--
SIL.Chorus.Mercurial's CopyFiles target drops the platform hg (Mercurial/) and the cross-platform
fixutf8 extension (MercurialExtensions/, from runtimes/any) into the project dir during Build.
fixutf8 extension (MercurialExtensions/, from runtimes/any) into $(Mercurial4ChorusDestDir) during Build.
That happens after project evaluation, so a static <Content Include> glob misses the files on a
clean build. Stage them into the output dir here, after CopyFiles has populated the project dir,
so Chorus finds MercurialExtensions/fixutf8 at runtime (it resolves it relative to the test
assembly). On Linux the linux-x64 hg is already copied to output by NuGet's runtime-asset handling
(VerifyHgWorking confirms it), so only MercurialExtensions needs staging there; the win hg has no
standard RID folder and is only staged on Windows.
clean build. Stage them into the output dir here, after CopyFiles has populated it. Chorus
resolves MercurialExtensions/fixutf8 relative to the test assembly, and looks for Mercurial/ only
in the assembly's dir, grandparent and great-grandparent (MercurialLocation), so both must be in
the output dir on every platform.
-->
<Target Name="StageMercurialForChorus" AfterTargets="Build">
<ItemGroup>
<_HgExtDrop Include="$(MSBuildProjectDirectory)/MercurialExtensions/**" />
<_HgBinDrop Include="$(MSBuildProjectDirectory)/Mercurial/**"
Condition="$([MSBuild]::IsOSPlatform('Windows'))" />
<_HgExtDrop Include="$(Mercurial4ChorusDestDir)/MercurialExtensions/**" />
<_HgBinDrop Include="$(Mercurial4ChorusDestDir)/Mercurial/**" />
</ItemGroup>
<Copy SourceFiles="@(_HgExtDrop)"
DestinationFiles="@(_HgExtDrop->'$(OutDir)MercurialExtensions/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_HgBinDrop)"
DestinationFiles="@(_HgBinDrop->'$(OutDir)Mercurial/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true"
Condition="$([MSBuild]::IsOSPlatform('Windows'))" />
SkipUnchangedFiles="true" />
</Target>

</Project>
Loading
Loading