Skip to content
Open
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
37 changes: 37 additions & 0 deletions csharp/src/HgResume.Api/ApiConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,37 @@ public sealed class ApiConfig
/// </summary>
public required bool RequireManageSecret { get; init; }

/// <summary>Default upper bound on a client-supplied chunkSize (20 MB, matching the Chorus client).</summary>
public const int DefaultChunkSizeMax = 20 * 1024 * 1024;

/// <summary>
/// Upper bound the server clamps a client-supplied <c>chunkSize</c> to. <see cref="GetChunkAsync"/>
/// already caps each read at the remaining bundle length, so this is cheap defense against a client
/// asking for an absurd allocation.
/// </summary>
public int ChunkSizeMax { get; init; } = DefaultChunkSizeMax;

/// <summary>
/// How old a <c>.async_run</c> lock that is NOT tracked by this process's in-memory registry must be
/// before it is treated as a dead generation (API restarted mid-bundle, lock file left behind) and
/// cleaned up so a new generation can be spawned. A live generation in this process is recognised via
/// the registry regardless of age, so this only governs cross-restart recovery. Default 2 minutes.
/// </summary>
public TimeSpan StaleBundleLockThreshold { get; init; } = TimeSpan.FromSeconds(120);

/// <summary>
/// Maximum number of times the server will (re)spawn bundle generation for a single pull transaction
/// before giving up with FAIL, so a bundle that genuinely cannot be produced fails fast instead of
/// respawning hg forever. Default 3.
/// </summary>
public int MaxBundleGenAttempts { get; init; } = 3;

/// <summary>Age after which the cache GC reaps a <c>.bundle/.metadata/.async_run/.incoming</c> file. Default 24h.</summary>
public TimeSpan CacheTtl { get; init; } = TimeSpan.FromHours(24);

/// <summary>How often the cache GC background service runs. Default 1h.</summary>
public TimeSpan CacheGcInterval { get; init; } = TimeSpan.FromMinutes(60);

public static ApiConfig FromEnvironment(bool isDevelopment)
{
string cache = Env("HGRESUME_CACHE_PATH", "/var/cache/hgresume");
Expand All @@ -59,6 +90,12 @@ public static ApiConfig FromEnvironment(bool isDevelopment)
ResetCleanupAgeDays = EnvInt("HGRESUME_RESET_CLEANUP_AGE_DAYS", 31),
ManageSecret = Environment.GetEnvironmentVariable("HGRESUME_MANAGE_SECRET") is { Length: > 0 } s ? s : null,
RequireManageSecret = EnvBool("HGRESUME_REQUIRE_MANAGE_SECRET", !isDevelopment),
ChunkSizeMax = EnvInt("HGRESUME_CHUNK_SIZE_MAX", DefaultChunkSizeMax),
StaleBundleLockThreshold =
TimeSpan.FromSeconds(EnvInt("HGRESUME_STALE_BUNDLE_LOCK_SECONDS", 120)),
MaxBundleGenAttempts = EnvInt("HGRESUME_MAX_BUNDLE_GEN_ATTEMPTS", 3),
CacheTtl = TimeSpan.FromHours(EnvInt("HGRESUME_CACHE_TTL_HOURS", 24)),
CacheGcInterval = TimeSpan.FromMinutes(EnvInt("HGRESUME_CACHE_GC_INTERVAL_MINUTES", 60)),
};
}

Expand Down
29 changes: 29 additions & 0 deletions csharp/src/HgResume.Api/AsyncRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ await File.WriteAllTextAsync(lockFile,

public bool IsRunning() => File.Exists(_lockFile);

/// <summary>
/// True when this lock is backed by a background task still running in THIS process. Survives as a
/// real liveness signal (unlike a bare lock file, which also persists across a process restart that
/// killed the generation). Used to tell an in-flight bundle from a dead lock left by a crash.
/// </summary>
public bool IsTrackedRunning() => Running.ContainsKey(_lockFile);

/// <summary>
/// True when a lock file exists for a generation this process is NOT running (not in <see
/// cref="Running"/>) and it was last written longer than <paramref name="threshold"/> ago β€” i.e. a
/// generation abandoned by a crash/restart. Such a lock must be cleaned up and respawned rather than
/// polled forever. A lock this process IS running is never stale regardless of age.
/// </summary>
public bool IsStaleLock(TimeSpan threshold)
{
if (!File.Exists(_lockFile) || Running.ContainsKey(_lockFile))
{
return false;
}
try
{
return DateTime.UtcNow - File.GetLastWriteTimeUtc(_lockFile) > threshold;
}
catch
{
return false; // if we can't stat it, don't treat it as reapable
}
}

public async Task<bool> IsCompleteAsync(CancellationToken ct = default)
{
if (!File.Exists(_lockFile))
Expand Down
91 changes: 91 additions & 0 deletions csharp/src/HgResume.Api/CacheGarbageCollector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
namespace HgResume.Api;

/// <summary>
/// Periodically reaps abandoned transaction artifacts from <see cref="ApiConfig.CachePath"/>.
///
/// Only <c>finishPull/PushBundle</c> cleaned these up, so a client that looped (or simply went away)
/// left its <c>.bundle/.metadata/.async_run/.incoming</c> files behind forever. The July 2026 incident
/// left <b>1,258</b> leaked <c>.async_run</c> locks. The PHP deployment would have needed a cron job;
/// the .NET host is long-running, so a <see cref="BackgroundService"/> fits without any image
/// process-model change.
/// </summary>
public sealed class CacheGarbageCollector : BackgroundService
{
// Transaction artifacts BundleHelper/AsyncRunner write into the cache dir. Repo-reset backups live
// under their own TempRepoFolder and are reaped by RepoManageService, so we don't touch those.
private static readonly string[] Extensions = { ".bundle", ".metadata", ".async_run", ".incoming" };

private readonly ApiConfig _config;
private readonly ILogger<CacheGarbageCollector> _logger;

public CacheGarbageCollector(ApiConfig config, ILogger<CacheGarbageCollector> logger)
{
_config = config;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once at startup (reap anything a prior process left behind), then on the configured cadence.
while (!stoppingToken.IsCancellationRequested)
{
try
{
Collect();
}
catch (Exception e)
{
_logger.LogWarning(e, "Cache GC pass failed");
}

try
{
await Task.Delay(_config.CacheGcInterval, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style comment only. This function is an example of why I dislike the "opening and closing braces on their own line" style that seems to have become standard in C#. I would much rather see this code written as:

try { Collect(); }
catch (Exception e) { _logger.LogWarning(e, "Cache GC pass failed"); }

try { await Task.Delay(_config.CacheGcInterval, stoppingToken); }
catch (OperationCanceledException) { break ; }

5 lines instead of 17, and taking up much less vertical space on-screen. With functions taking up less vertical space, it's easier to read them in the context of surrounding code, rather than scrolling up and down to see surrounding context.

Very much a style nitpick. But this is one of the things that bugs me every time I see it, and this while loop is one of the best examples I've ever seen where readability would have been much improved by having fewer lines of code.

}
}

/// <summary>Deletes cache artifacts whose last-write time is older than <see cref="ApiConfig.CacheTtl"/>.</summary>
public int Collect()
{
if (!Directory.Exists(_config.CachePath))
{
return 0;
}

DateTime cutoff = DateTime.UtcNow - _config.CacheTtl;
int removed = 0;
foreach (string path in Directory.EnumerateFiles(_config.CachePath))
{
if (!Extensions.Contains(Path.GetExtension(path)))
{
continue;
}
try
{
if (File.GetLastWriteTimeUtc(path) < cutoff)
{
File.Delete(path);
removed++;
}
}
catch (Exception e)
{
// A file deleted or rewritten by a concurrent request between stat and delete is fine;
// just skip it this pass.
_logger.LogDebug(e, "Cache GC could not reap {Path}", path);
}
}

if (removed > 0)
{
_logger.LogInformation("Cache GC reaped {Count} stale cache file(s) from {Path}",
removed, _config.CachePath);
}
return removed;
}
}
57 changes: 50 additions & 7 deletions csharp/src/HgResume.Api/HgResumeApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,13 @@ public async Task<HgResumeResponse> PullBundleChunkInternalAsync(string repoId,
{
return Fail("invalid offset");
}
// Clamp a client-supplied chunkSize to a configured cap. GetChunkAsync already bounds each
// read to the remaining bundle length, so this is cheap defense against an absurd value (and
// keeps offset + chunkSize from overflowing in the size checks below).
if (chunkSize > _config.ChunkSizeMax)
{
chunkSize = _config.ChunkSizeMax;
}

var hg = new HgRunner(repoPath);
if (!await hg.IsValidBaseAsync(baseHashes, ct))
Expand Down Expand Up @@ -248,13 +255,49 @@ public async Task<HgResumeResponse> PullBundleChunkInternalAsync(string repoId,
["Error"] = "Cannot request data for bundle that doesnt exist yet",
});
}
// first pull request (offset == 0): make a new bundle
asyncRunner = waitForBundleToFinish
? await hg.MakeBundleAndWaitUntilFinishedAsync(sortedBase, bundleFilename, ct)
: hg.MakeBundle(sortedBase, bundleFilename);
bundle.SetProp("tip", await hg.GetTipAsync(ct));
bundle.SetProp("repoId", repoId);
bundle.State = BundleHelper.State_Bundle;

// Concurrent-generation guard + crash recovery. A duplicate first-chunk retry can arrive
// before the .bundle file appears; without this it spawns a second `hg bundle` for the same
// transId. A generation is "in flight" if a background task in this process is still running
// it, or a lock file exists that is not yet old enough to be considered abandoned.
bool generationInFlight = asyncRunner.IsTrackedRunning() ||
(asyncRunner.IsRunning() && !asyncRunner.IsStaleLock(_config.StaleBundleLockThreshold));

if (generationInFlight)
{
// Someone else is already building this bundle. Don't respawn; poll it via State_Bundle.
if (bundle.State != BundleHelper.State_Bundle &&
bundle.State != BundleHelper.State_Downloading)
{
bundle.State = BundleHelper.State_Bundle;
}
}
else
{
// Reap a stale lock left by a generation this process no longer runs (API restarted
// mid-bundle) so it can't deadlock the transaction, then respawn.
if (asyncRunner.IsRunning())
{
asyncRunner.CleanUp();
}

// Bound respawns so a bundle that genuinely can't be produced fails fast with FAIL
// instead of respawning hg forever (the OOM loop).
int attempts = (int.TryParse(bundle.GetProp("genAttempts"), out var prev) ? prev : 0) + 1;
if (attempts > _config.MaxBundleGenAttempts)
{
return Fail($"bundle generation failed after {attempts - 1} attempts");
}
bundle.SetProp("genAttempts", attempts.ToString());

// first pull request (offset == 0): make a new bundle
asyncRunner = waitForBundleToFinish
? await hg.MakeBundleAndWaitUntilFinishedAsync(sortedBase, bundleFilename, ct)
: hg.MakeBundle(sortedBase, bundleFilename);
bundle.SetProp("tip", await hg.GetTipAsync(ct));
bundle.SetProp("repoId", repoId);
bundle.State = BundleHelper.State_Bundle;
}
}

var response = new HgResumeResponse(HgResumeResponse.SUCCESS);
Expand Down
Loading
Loading