From ad5dc324aaf84eda2cf4d72cd30f92e9292ca77c Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Tue, 25 Aug 2026 20:46:32 +0800
Subject: [PATCH 01/17] feat(hosting): make the HTTP worker-queue timeout
configurable
The wait for a free HTTP runspace before a request is shed with 503 was a
hardcoded TimeSpan.FromSeconds(30) at both checkout sites in
PowerShellRunnerService. Surface it as Worker:HttpQueueTimeoutSeconds with a
CRAFT_HTTP_QUEUE_TIMEOUT env override, resolved once via
CraftHostBuilderExtensions.ResolveHttpQueueTimeout (env > setting > built-in
30s default), mirroring how MinThreads resolves. This is a load-shedding
bound, not a capacity knob.
---
Services/Configuration/WorkerSettings.cs | 23 ++++++
.../Hosting/CraftHostBuilderExtensions.cs | 25 +++++++
.../PowerShellHost/PowerShellRunnerService.cs | 12 ++-
appsettings.example.jsonc | 8 ++
tests/Craft.Tests/HttpQueueTimeoutTests.cs | 75 +++++++++++++++++++
5 files changed, 140 insertions(+), 3 deletions(-)
create mode 100644 tests/Craft.Tests/HttpQueueTimeoutTests.cs
diff --git a/Services/Configuration/WorkerSettings.cs b/Services/Configuration/WorkerSettings.cs
index 7ed4dba..5062e0e 100644
--- a/Services/Configuration/WorkerSettings.cs
+++ b/Services/Configuration/WorkerSettings.cs
@@ -75,6 +75,29 @@ public class WorkerSettings
///
public int BgTimeoutSeconds { get; set; }
+ ///
+ /// How long an incoming HTTP request waits for a free PowerShell runspace when every worker in the
+ /// HTTP pool is already busy, before it is shed with 503 "Server busy, please retry".
+ ///
+ ///
+ /// This is a load-shedding bound, NOT a capacity or execution knob. It does not add throughput —
+ /// under sustained saturation it only changes how long callers wait before the 503 (longer waits
+ /// hold connections and lengthen the tail). Its value is in absorbing brief bursts: a
+ /// request that would have got a worker a few seconds later completes instead of failing spuriously.
+ /// When 503s appear under steady load the levers are /
+ /// / a larger host, not this.
+ ///
+ ///
+ ///
+ /// Distinct from (which bounds how long a request may execute
+ /// once it holds a worker). 0 or negative = the built-in default of 30 seconds.
+ ///
+ ///
+ /// Env override: CRAFT_HTTP_QUEUE_TIMEOUT (seconds), which wins over this setting.
+ /// Resolved by CraftHostBuilderExtensions.ResolveHttpQueueTimeout.
+ ///
+ public int HttpQueueTimeoutSeconds { get; set; }
+
///
/// Environment variables to inject into every PowerShell runspace.
/// Use "{ApiBasePath}" as a placeholder — it will be replaced with the resolved API directory at startup.
diff --git a/Services/Hosting/CraftHostBuilderExtensions.cs b/Services/Hosting/CraftHostBuilderExtensions.cs
index a331e3d..544d882 100644
--- a/Services/Hosting/CraftHostBuilderExtensions.cs
+++ b/Services/Hosting/CraftHostBuilderExtensions.cs
@@ -74,6 +74,31 @@ public static int ResolveMinThreads(CraftSettings settings)
return Math.Max(baseline, forPools);
}
+ /// The built-in HTTP worker-checkout wait when nothing overrides it.
+ public const int DefaultHttpQueueTimeoutSeconds = 30;
+
+ ///
+ /// Resolves how long an HTTP request waits for a free runspace before it is shed with 503:
+ /// the CRAFT_HTTP_QUEUE_TIMEOUT env var (seconds) wins, then an explicit
+ /// Worker:HttpQueueTimeoutSeconds, otherwise the built-in
+ /// . See
+ /// for why this is a load-shedding bound and not a capacity knob.
+ ///
+ public static TimeSpan ResolveHttpQueueTimeout(WorkerSettings worker)
+ {
+ ArgumentNullException.ThrowIfNull(worker);
+
+ if (int.TryParse(
+ Environment.GetEnvironmentVariable("CRAFT_HTTP_QUEUE_TIMEOUT"),
+ NumberStyles.Integer, CultureInfo.InvariantCulture, out var fromEnv) && fromEnv > 0)
+ return TimeSpan.FromSeconds(fromEnv);
+
+ if (worker.HttpQueueTimeoutSeconds > 0)
+ return TimeSpan.FromSeconds(worker.HttpQueueTimeoutSeconds);
+
+ return TimeSpan.FromSeconds(DefaultHttpQueueTimeoutSeconds);
+ }
+
///
/// Kestrel limits: request timeouts, HTTP/2 tuning, and the DoS-relevant caps (body size,
/// connection count, slow-loris minimum data rates). The caps apply regardless of the timeout.
diff --git a/Services/PowerShellHost/PowerShellRunnerService.cs b/Services/PowerShellHost/PowerShellRunnerService.cs
index 1c9b872..6863f25 100644
--- a/Services/PowerShellHost/PowerShellRunnerService.cs
+++ b/Services/PowerShellHost/PowerShellRunnerService.cs
@@ -26,6 +26,10 @@ public class PowerShellRunnerService : IDisposable
private readonly AuthSettings _authSettings;
private readonly ScriptRepoSettings _scriptsSettings;
+ // How long an HTTP request waits for a free runspace before it is shed with 503. Resolved once
+ // (env override → Worker:HttpQueueTimeoutSeconds → built-in default) — see ResolveHttpQueueTimeout.
+ private readonly TimeSpan _httpQueueTimeout;
+
// Static JsonSerializerOptions — allocated once, reused everywhere
private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = false };
@@ -45,6 +49,7 @@ public PowerShellRunnerService(
_workerSettings = settings.Worker;
_authSettings = settings.Auth;
_scriptsSettings = settings.Scripts;
+ _httpQueueTimeout = CraftHostBuilderExtensions.ResolveHttpQueueTimeout(settings.Worker);
}
///
@@ -90,7 +95,7 @@ public async Task ExecuteHttpEndpoint(string endpoint, Hashtable r
EventHandler? onVerbose = null;
try
{
- worker = _pool.CheckoutHttp(TimeSpan.FromSeconds(30));
+ worker = _pool.CheckoutHttp(_httpQueueTimeout);
if (worker == null)
{
return new ScriptResult
@@ -278,11 +283,12 @@ private async Task ExecuteHttpScriptInternal(string route, Hashtab
var checkoutStart = timing != null ? Stopwatch.GetTimestamp() : 0;
if (isHttp)
{
- worker = _pool.CheckoutHttp(TimeSpan.FromSeconds(30));
+ worker = _pool.CheckoutHttp(_httpQueueTimeout);
if (timing != null) timing.CheckoutTicks = Stopwatch.GetTimestamp() - checkoutStart;
if (worker == null)
{
- _logger.LogWarning("HTTP pool exhausted — no worker available within 30s for {Route}", route);
+ _logger.LogWarning("HTTP pool exhausted — no worker available within {Timeout:0}s for {Route}",
+ _httpQueueTimeout.TotalSeconds, route);
return new ScriptResult
{
StatusCode = 503,
diff --git a/appsettings.example.jsonc b/appsettings.example.jsonc
index d6159b4..b740baf 100644
--- a/appsettings.example.jsonc
+++ b/appsettings.example.jsonc
@@ -163,6 +163,14 @@
// 0 = no timeout (default). Recommended: 600-3600 for background jobs
// "BgTimeoutSeconds": 0,
+ // How long an HTTP request waits for a free runspace when the pool is saturated before it is
+ // shed with 503 "Server busy, please retry". A load-shedding bound, NOT a capacity knob — it
+ // does not add throughput, it only decides how long callers wait before a spurious 503. Widen
+ // it to absorb brief bursts; raise HttpPoolSize/MinThreads for steady saturation. Distinct from
+ // HttpTimeoutSeconds (which bounds execution once a worker is held).
+ // 0 = built-in default of 30s. Env override: CRAFT_HTTP_QUEUE_TIMEOUT (seconds).
+ // "HttpQueueTimeoutSeconds": 30,
+
// Extra env vars injected into every runspace
// "EnvVars": { "MY_VAR": "value" },
diff --git a/tests/Craft.Tests/HttpQueueTimeoutTests.cs b/tests/Craft.Tests/HttpQueueTimeoutTests.cs
new file mode 100644
index 0000000..09aaf39
--- /dev/null
+++ b/tests/Craft.Tests/HttpQueueTimeoutTests.cs
@@ -0,0 +1,75 @@
+using Craft.Configuration;
+using Craft.Hosting;
+
+namespace Craft.Tests;
+
+///
+/// The HTTP queue timeout bounds how long a request waits for a free runspace before it is shed with
+/// 503. It resolves from the CRAFT_HTTP_QUEUE_TIMEOUT env var, then Worker:HttpQueueTimeoutSeconds,
+/// then the built-in default — mirroring how the thread-pool minimum resolves.
+///
+public class HttpQueueTimeoutTests : IDisposable
+{
+ private readonly string? _original = Environment.GetEnvironmentVariable("CRAFT_HTTP_QUEUE_TIMEOUT");
+
+ public HttpQueueTimeoutTests() => Environment.SetEnvironmentVariable("CRAFT_HTTP_QUEUE_TIMEOUT", null);
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("CRAFT_HTTP_QUEUE_TIMEOUT", _original);
+ GC.SuppressFinalize(this);
+ }
+
+ private static WorkerSettings Worker(int queueTimeout = 0) =>
+ new() { HttpQueueTimeoutSeconds = queueTimeout };
+
+ [Fact]
+ public void UnsetFallsBackToTheBuiltInDefault()
+ {
+ Assert.Equal(
+ TimeSpan.FromSeconds(CraftHostBuilderExtensions.DefaultHttpQueueTimeoutSeconds),
+ CraftHostBuilderExtensions.ResolveHttpQueueTimeout(Worker()));
+ }
+
+ [Fact]
+ public void ExplicitSettingWins()
+ {
+ Assert.Equal(
+ TimeSpan.FromSeconds(60),
+ CraftHostBuilderExtensions.ResolveHttpQueueTimeout(Worker(queueTimeout: 60)));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void ZeroOrNegativeSettingFallsBackToTheDefault(int configured)
+ {
+ Assert.Equal(
+ TimeSpan.FromSeconds(CraftHostBuilderExtensions.DefaultHttpQueueTimeoutSeconds),
+ CraftHostBuilderExtensions.ResolveHttpQueueTimeout(Worker(configured)));
+ }
+
+ [Fact]
+ public void EnvOverrideWinsOverTheSetting()
+ {
+ Environment.SetEnvironmentVariable("CRAFT_HTTP_QUEUE_TIMEOUT", "90");
+
+ Assert.Equal(
+ TimeSpan.FromSeconds(90),
+ CraftHostBuilderExtensions.ResolveHttpQueueTimeout(Worker(queueTimeout: 60)));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("notanumber")]
+ [InlineData("0")]
+ [InlineData("-1")]
+ public void InvalidOrNonPositiveEnvIsIgnored(string value)
+ {
+ Environment.SetEnvironmentVariable("CRAFT_HTTP_QUEUE_TIMEOUT", value);
+
+ Assert.Equal(
+ TimeSpan.FromSeconds(60),
+ CraftHostBuilderExtensions.ResolveHttpQueueTimeout(Worker(queueTimeout: 60)));
+ }
+}
From 3a90cb11b6fdbaa803fc8a43aea2540a3ac3419e Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Tue, 25 Aug 2026 20:53:00 +0800
Subject: [PATCH 02/17] feat(hosting): log rate-limit rejections when a 429 is
sent
The rate limiter set Retry-After on a throttled request but logged nothing, so
every 429 went out invisible to operators. Emit a warning from OnRejected
naming the partition the limit fired for (authenticated principal, else client
address) plus the method, path and Retry-After. The logger is resolved per
rejection (service registration has no built provider yet) and any logging
failure is swallowed so it can never turn a throttle into a 500.
---
.../Hosting/CraftHostBuilderExtensions.cs | 24 +++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/Services/Hosting/CraftHostBuilderExtensions.cs b/Services/Hosting/CraftHostBuilderExtensions.cs
index 544d882..d341762 100644
--- a/Services/Hosting/CraftHostBuilderExtensions.cs
+++ b/Services/Hosting/CraftHostBuilderExtensions.cs
@@ -316,9 +316,29 @@ public static IServiceCollection AddCraftRateLimiter(
// honour unprompted, so emitting it is what makes the limit self-documenting.
options.OnRejected = (context, _) =>
{
+ var retryAfter = ResolveRetryAfterSeconds(context.Lease, window);
context.HttpContext.Response.Headers.RetryAfter =
- ResolveRetryAfterSeconds(context.Lease, window)
- .ToString(CultureInfo.InvariantCulture);
+ retryAfter.ToString(CultureInfo.InvariantCulture);
+
+ // A 429 is otherwise invisible — the caller sees it, the operator does not. Log the
+ // client the limit fired for (the same partition key the limiter counts against) so a
+ // throttled integration or a runaway loop can be identified. Warning, not Error: this
+ // is an expected, client-caused outcome, but one worth surfacing. Resolved per
+ // rejection because service registration has no built provider yet; CreateLogger is
+ // cheap and the factory caches per category. Never let logging break the response.
+ try
+ {
+ context.HttpContext.RequestServices.GetService()?
+ .CreateLogger("Craft.Hosting.RateLimiter")
+ .LogWarning(
+ "Rate limit exceeded — 429 for {Client} on {Method} {Path}; Retry-After {RetryAfter}s",
+ RateLimitPartitionKey.Resolve(context.HttpContext),
+ context.HttpContext.Request.Method,
+ context.HttpContext.Request.Path.Value,
+ retryAfter);
+ }
+ catch { /* logging must never turn a throttle into a 500 */ }
+
return ValueTask.CompletedTask;
};
From af2140813a13b9012d0492caf99d515f4ff4eeac Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Tue, 25 Aug 2026 21:17:19 +0800
Subject: [PATCH 03/17] feat(hosting): cap per-client API concurrency, chained
onto the rate limiter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add an optional per-client concurrency cap on app-only API callers so one
automation cannot hold every runspace at once and starve the interactive UI,
which is never capped. Config RateLimit:ApiConcurrencyLimit (env override
CRAFT_API_CONCURRENCY_LIMIT), 0 = off by default.
Because the limiter's lease spans the whole downstream pipeline, a permit
covers both the wait for a runspace and execution — so the cap counts
in-flight and queued-for-a-worker requests alike. Over-limit is rejected
immediately with 429 (QueueLimit 0), reusing the existing Retry-After + log
path. Callers are classified by a new, tested CallerClassifier (idp=aad plus a
GUID AppId principal). The per-client rate limiter and the concurrency cap are
built as chained partitioned limiters, and the middleware now runs whenever
either is active.
---
Services/Configuration/RateLimitSettings.cs | 36 +++++
Services/Hosting/CallerClassifier.cs | 31 ++++
.../Hosting/CraftHostBuilderExtensions.cs | 73 +++++++--
Services/Program.cs | 2 +-
appsettings.example.jsonc | 17 ++-
tests/Craft.Tests/CallerClassifierTests.cs | 139 ++++++++++++++++++
6 files changed, 279 insertions(+), 19 deletions(-)
create mode 100644 Services/Hosting/CallerClassifier.cs
create mode 100644 tests/Craft.Tests/CallerClassifierTests.cs
diff --git a/Services/Configuration/RateLimitSettings.cs b/Services/Configuration/RateLimitSettings.cs
index 2fb3ca9..c168026 100644
--- a/Services/Configuration/RateLimitSettings.cs
+++ b/Services/Configuration/RateLimitSettings.cs
@@ -25,8 +25,44 @@ public class RateLimitSettings
///
public int QueueLimit { get; set; }
+ ///
+ /// Maximum requests a single app-only API client (client-credentials caller) may have occupying
+ /// the HTTP worker system at once — counting both those queued waiting for a runspace and those
+ /// already executing, since the limiter's lease spans the whole downstream pipeline. Keyed per
+ /// client (its AppId), so one automation cannot monopolise the pool and starve the interactive UI,
+ /// which is never limited by this. Over-limit requests are rejected immediately with 429 (no
+ /// concurrency queue); the caller retries on Retry-After.
+ ///
+ ///
+ /// 0 (default) = unlimited: the feature is off until a value is set. Distinct from
+ /// , which is a request-RATE cap; this is a simultaneous-in-flight cap.
+ /// Interactive (browser) callers are classified as UI and never counted here.
+ ///
+ ///
+ /// Env override: CRAFT_API_CONCURRENCY_LIMIT.
+ ///
+ public int ApiConcurrencyLimit { get; set; }
+
/// Resolved enabled state, honouring the CRAFT_RATELIMIT_ENABLED environment override.
public bool IsEnabled =>
Enabled
|| string.Equals(Environment.GetEnvironmentVariable("CRAFT_RATELIMIT_ENABLED"), "true", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Resolved per-client API concurrency cap, honouring the CRAFT_API_CONCURRENCY_LIMIT
+ /// environment override (which wins when it parses to a non-negative integer). 0 = unlimited/off.
+ ///
+ public int ResolvedApiConcurrencyLimit =>
+ int.TryParse(Environment.GetEnvironmentVariable("CRAFT_API_CONCURRENCY_LIMIT"),
+ System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture,
+ out var fromEnv) && fromEnv >= 0
+ ? fromEnv
+ : ApiConcurrencyLimit;
+
+ ///
+ /// Whether the rate-limiter middleware needs to run at all: either the per-client rate limiter is
+ /// enabled, or an API concurrency cap is configured. When both are off, the middleware is skipped
+ /// entirely (no per-request limiter cost).
+ ///
+ public bool RequiresLimiterMiddleware => IsEnabled || ResolvedApiConcurrencyLimit > 0;
}
diff --git a/Services/Hosting/CallerClassifier.cs b/Services/Hosting/CallerClassifier.cs
new file mode 100644
index 0000000..daa62b6
--- /dev/null
+++ b/Services/Hosting/CallerClassifier.cs
@@ -0,0 +1,31 @@
+namespace Craft.Hosting;
+
+///
+/// Classifies a request as an app-only API client versus an interactive (UI) caller, from the
+/// normalised principal headers writes.
+///
+/// The distinction is load-bearing for the API concurrency cap: app-only automation must not be able
+/// to monopolise the shared worker pool and starve interactive users, who are never limited by it.
+/// The rule is exactly the one the hosted app already keys off — a client-credentials caller arrives
+/// with x-ms-client-principal-idp: aad and its AppId (a GUID) as the principal name, whereas an
+/// interactive Entra user is normalised to azureStaticWebApps. Both conditions are required so a
+/// stray aad idp on a non-GUID principal is never misread as an API client.
+///
+///
+public static class CallerClassifier
+{
+ ///
+ /// True when is an app-only API client (idp is aad and the
+ /// principal name parses as a GUID AppId). Depends on running after .
+ ///
+ public static bool IsApiClient(HttpContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var idp = context.Request.Headers["x-ms-client-principal-idp"].ToString();
+ if (!string.Equals(idp, "aad", StringComparison.OrdinalIgnoreCase)) return false;
+
+ var name = context.Request.Headers["x-ms-client-principal-name"].ToString();
+ return Guid.TryParse(name, out _);
+ }
+}
diff --git a/Services/Hosting/CraftHostBuilderExtensions.cs b/Services/Hosting/CraftHostBuilderExtensions.cs
index d341762..e649528 100644
--- a/Services/Hosting/CraftHostBuilderExtensions.cs
+++ b/Services/Hosting/CraftHostBuilderExtensions.cs
@@ -293,9 +293,16 @@ public static int ResolveRetryAfterSeconds(RateLimitLease lease, TimeSpan window
}
///
- /// Per-client fixed-window rate limiter so a single caller cannot exhaust the small HTTP worker
- /// pool. Enabled by default; turn off with App:RateLimit:Enabled=false. Throttled requests
- /// get a 429 carrying Retry-After.
+ /// The request limiter, a chain of up to two partitioned limiters sharing one 429 + Retry-After
+ /// rejection path:
+ ///
+ /// A per-client fixed-window rate limiter (on by default) so a single
+ /// caller cannot exhaust the small HTTP worker pool; turn off with App:RateLimit:Enabled=false.
+ /// A per-client concurrency cap on app-only API callers
+ /// (App:RateLimit:ApiConcurrencyLimit, off by default) so one automation cannot hold every
+ /// runspace at once and starve the interactive UI, which is never capped.
+ ///
+ /// The middleware is skipped entirely when neither is active.
///
public static IServiceCollection AddCraftRateLimiter(
this IServiceCollection services, CraftSettings settings)
@@ -303,9 +310,10 @@ public static IServiceCollection AddCraftRateLimiter(
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(settings);
- if (!settings.RateLimit.IsEnabled) return services;
+ var rl = settings.RateLimit;
+ if (!rl.RequiresLimiterMiddleware) return services;
- var window = TimeSpan.FromSeconds(Math.Max(1, settings.RateLimit.WindowSeconds));
+ var window = TimeSpan.FromSeconds(Math.Max(1, rl.WindowSeconds));
services.AddRateLimiter(options =>
{
@@ -342,16 +350,51 @@ public static IServiceCollection AddCraftRateLimiter(
return ValueTask.CompletedTask;
};
- options.GlobalLimiter = PartitionedRateLimiter.Create(context =>
- RateLimitPartition.GetFixedWindowLimiter(
- RateLimitPartitionKey.Resolve(context),
- _ => new FixedWindowRateLimiterOptions
- {
- PermitLimit = Math.Max(1, settings.RateLimit.PermitPerWindow),
- Window = window,
- QueueLimit = Math.Max(0, settings.RateLimit.QueueLimit),
- QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
- }));
+ // Built as a chain so a request must satisfy every active limiter. Order is immaterial —
+ // rejection by either sheds the request through the one OnRejected above.
+ var limiters = new List>(2);
+
+ if (rl.IsEnabled)
+ {
+ // Per-client request RATE. Partition key is the authenticated principal (else address).
+ limiters.Add(PartitionedRateLimiter.Create(context =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ RateLimitPartitionKey.Resolve(context),
+ _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = Math.Max(1, rl.PermitPerWindow),
+ Window = window,
+ QueueLimit = Math.Max(0, rl.QueueLimit),
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ })));
+ }
+
+ var apiConcurrency = rl.ResolvedApiConcurrencyLimit;
+ if (apiConcurrency > 0)
+ {
+ // Per-client CONCURRENCY on app-only API callers only. The lease is held for the whole
+ // downstream pipeline, so a permit covers both the wait for a runspace and execution —
+ // that is what makes this cap "in flight + queued for a worker" rather than just one.
+ limiters.Add(PartitionedRateLimiter.Create(context =>
+ {
+ if (!CallerClassifier.IsApiClient(context))
+ return RateLimitPartition.GetNoLimiter("ui");
+
+ // Keyed on the AppId so each client's budget is its own, not shared across clients.
+ return RateLimitPartition.GetConcurrencyLimiter(
+ context.Request.Headers["x-ms-client-principal-name"].ToString(),
+ _ => new ConcurrencyLimiterOptions
+ {
+ PermitLimit = apiConcurrency,
+ QueueLimit = 0, // fail fast: the excess is rejected, not parked
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ });
+ }));
+ }
+
+ options.GlobalLimiter = limiters.Count == 1
+ ? limiters[0]
+ : PartitionedRateLimiter.CreateChained(limiters.ToArray());
});
return services;
diff --git a/Services/Program.cs b/Services/Program.cs
index 2bfdcb5..6cef08a 100644
--- a/Services/Program.cs
+++ b/Services/Program.cs
@@ -382,7 +382,7 @@ void RunInitialization()
// counting those against the caller's budget could throttle a user for opening a page.
// Left outside the capHttp block on purpose: a frontend-only node has no auth middleware and no
// worker pool, but should still be protected, partitioned by origin address as before.
-if (CraftSettings.RateLimit.IsEnabled)
+if (CraftSettings.RateLimit.RequiresLimiterMiddleware)
app.UseRateLimiter();
// Concurrent request tracking for diagnostics. A holder object, not an int: the dispatch endpoint
diff --git a/appsettings.example.jsonc b/appsettings.example.jsonc
index b740baf..632997b 100644
--- a/appsettings.example.jsonc
+++ b/appsettings.example.jsonc
@@ -80,11 +80,22 @@
// collapsed into a single bucket. Requests over the limit get HTTP 429 with a Retry-After header.
// Set "Enabled": false to turn it off (e.g. for load/perf testing); CRAFT_RATELIMIT_ENABLED=true
// can also force it on.
+ //
+ // ApiConcurrencyLimit adds a second, orthogonal cap: the most requests a single app-only API
+ // client (client-credentials caller, idp=aad) may have occupying the worker system AT ONCE —
+ // counting both those queued waiting for a runspace and those executing, since the limiter's lease
+ // spans the whole downstream pipeline. Keyed per client (its AppId), so one automation cannot hold
+ // every runspace and starve the interactive UI, which is never capped by this. A rate cap
+ // (PermitPerWindow) bounds requests/second; this bounds simultaneous-in-flight. 0 (default) = off.
+ // Over-limit requests are rejected immediately with 429 + Retry-After (no concurrency queue).
+ // Env override: CRAFT_API_CONCURRENCY_LIMIT. Works even with "Enabled": false (rate limiter off,
+ // concurrency cap on) — the limiter middleware runs whenever either is active.
// "RateLimit": {
// "Enabled": true,
- // "PermitPerWindow": 300, // requests allowed per window, per client
- // "WindowSeconds": 10, // window length in seconds
- // "QueueLimit": 0 // requests queued at the limit before rejecting (0 = reject immediately)
+ // "PermitPerWindow": 300, // requests allowed per window, per client
+ // "WindowSeconds": 10, // window length in seconds
+ // "QueueLimit": 0, // requests queued at the limit before rejecting (0 = reject immediately)
+ // "ApiConcurrencyLimit": 0 // max simultaneous in-flight requests per API client (0 = unlimited)
// },
// Realtime SSE channel served at /.craft/events. Downstream code publishes job events through
diff --git a/tests/Craft.Tests/CallerClassifierTests.cs b/tests/Craft.Tests/CallerClassifierTests.cs
new file mode 100644
index 0000000..c58ec62
--- /dev/null
+++ b/tests/Craft.Tests/CallerClassifierTests.cs
@@ -0,0 +1,139 @@
+using Craft.Configuration;
+using Craft.Hosting;
+using Microsoft.AspNetCore.Http;
+
+namespace Craft.Tests;
+
+///
+/// Classifying a caller as API vs UI gates the API concurrency cap, so a misclassification is a
+/// correctness bug in both directions: a mislabelled UI request would be capped (throttling a person),
+/// and a mislabelled API request would escape the cap it exists to enforce.
+///
+public class CallerClassifierTests
+{
+ private static DefaultHttpContext Request(string? idp = null, string? name = null)
+ {
+ var context = new DefaultHttpContext();
+ if (idp is not null) context.Request.Headers["x-ms-client-principal-idp"] = idp;
+ if (name is not null) context.Request.Headers["x-ms-client-principal-name"] = name;
+ return context;
+ }
+
+ [Fact]
+ public void AppOnlyClient_WithAadIdpAndGuidName_IsApi()
+ {
+ // Exactly what CraftAuthMiddleware writes for a client-credentials caller.
+ Assert.True(CallerClassifier.IsApiClient(
+ Request(idp: "aad", name: "11111111-2222-3333-4444-555555555555")));
+ }
+
+ [Fact]
+ public void InteractiveEntraUser_IsNotApi()
+ {
+ // A signed-in user is normalised to azureStaticWebApps with a UPN, never aad + GUID.
+ Assert.False(CallerClassifier.IsApiClient(
+ Request(idp: "azureStaticWebApps", name: "user@contoso.com")));
+ }
+
+ [Fact]
+ public void AadIdpButNonGuidName_IsNotApi()
+ {
+ // Belt-and-suspenders: a stray aad idp on a non-GUID principal must not be read as an API client.
+ Assert.False(CallerClassifier.IsApiClient(Request(idp: "aad", name: "user@contoso.com")));
+ }
+
+ [Fact]
+ public void GuidNameButNonAadIdp_IsNotApi()
+ {
+ Assert.False(CallerClassifier.IsApiClient(
+ Request(idp: "azureStaticWebApps", name: "11111111-2222-3333-4444-555555555555")));
+ }
+
+ [Fact]
+ public void AnonymousRequest_IsNotApi()
+ {
+ Assert.False(CallerClassifier.IsApiClient(new DefaultHttpContext()));
+ }
+
+ [Fact]
+ public void IdpMatchIsCaseInsensitive()
+ {
+ Assert.True(CallerClassifier.IsApiClient(
+ Request(idp: "AAD", name: "11111111-2222-3333-4444-555555555555")));
+ }
+}
+
+///
+/// The API concurrency cap must default off (unlimited) and honour its env override, and its presence
+/// must be what turns the limiter middleware on independently of the per-client rate limiter — so a
+/// deployment can run the concurrency cap with the rate limiter disabled, and vice versa.
+///
+public class ApiConcurrencyLimitSettingsTests : IDisposable
+{
+ private readonly string? _original = Environment.GetEnvironmentVariable("CRAFT_API_CONCURRENCY_LIMIT");
+
+ public ApiConcurrencyLimitSettingsTests() =>
+ Environment.SetEnvironmentVariable("CRAFT_API_CONCURRENCY_LIMIT", null);
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("CRAFT_API_CONCURRENCY_LIMIT", _original);
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public void DefaultsToOff()
+ {
+ var rl = new RateLimitSettings();
+ Assert.Equal(0, rl.ResolvedApiConcurrencyLimit);
+ }
+
+ [Fact]
+ public void ConfiguredValueIsUsed()
+ {
+ var rl = new RateLimitSettings { ApiConcurrencyLimit = 10 };
+ Assert.Equal(10, rl.ResolvedApiConcurrencyLimit);
+ }
+
+ [Fact]
+ public void EnvOverrideWins()
+ {
+ Environment.SetEnvironmentVariable("CRAFT_API_CONCURRENCY_LIMIT", "4");
+ var rl = new RateLimitSettings { ApiConcurrencyLimit = 10 };
+ Assert.Equal(4, rl.ResolvedApiConcurrencyLimit);
+ }
+
+ [Fact]
+ public void EnvOverrideOfZeroDisablesEvenWhenConfigured()
+ {
+ // A deployment-level kill switch: CRAFT_API_CONCURRENCY_LIMIT=0 turns the cap off regardless
+ // of the setting baked into appsettings.
+ Environment.SetEnvironmentVariable("CRAFT_API_CONCURRENCY_LIMIT", "0");
+ var rl = new RateLimitSettings { ApiConcurrencyLimit = 10 };
+ Assert.Equal(0, rl.ResolvedApiConcurrencyLimit);
+ }
+
+ [Theory]
+ [InlineData("notanumber")]
+ [InlineData("-3")]
+ public void InvalidOrNegativeEnvIsIgnored(string value)
+ {
+ Environment.SetEnvironmentVariable("CRAFT_API_CONCURRENCY_LIMIT", value);
+ var rl = new RateLimitSettings { ApiConcurrencyLimit = 7 };
+ Assert.Equal(7, rl.ResolvedApiConcurrencyLimit);
+ }
+
+ [Fact]
+ public void RateLimiterOff_ButConcurrencyOn_StillNeedsTheMiddleware()
+ {
+ var rl = new RateLimitSettings { Enabled = false, ApiConcurrencyLimit = 5 };
+ Assert.True(rl.RequiresLimiterMiddleware);
+ }
+
+ [Fact]
+ public void BothOff_SkipsTheMiddleware()
+ {
+ var rl = new RateLimitSettings { Enabled = false, ApiConcurrencyLimit = 0 };
+ Assert.False(rl.RequiresLimiterMiddleware);
+ }
+}
From d6012a3b9dcb5a0291178410c95d8b17689b9dd0 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Wed, 26 Aug 2026 08:29:44 +0800
Subject: [PATCH 04/17] feat(hosting): set the GC heap hard limit per host tier
via SkuProfiles
The image bakes a DOTNET_GCHeapHardLimit sized for the smallest tier, and
the CLR consumes it before any managed code runs - so larger tiers were
stuck with the small tier's heap cap. Let a SkuProfile carry an optional
GCHeapHardLimitMB and apply it on the matched profile at startup through
AppContext.SetData + GC.RefreshMemoryLimit (.NET 8), under the same
best-effort contract as pool sizing: any refusal logs and keeps the
baseline rather than failing startup.
---
Services/Configuration/SkuProfile.cs | 9 +++
Services/Hosting/GcHeapLimit.cs | 65 ++++++++++++++++++
Services/Hosting/SkuProfileSelector.cs | 4 ++
Services/Program.cs | 6 +-
appsettings.example.jsonc | 7 +-
tests/Craft.Tests/GcHeapLimitTests.cs | 94 ++++++++++++++++++++++++++
6 files changed, 182 insertions(+), 3 deletions(-)
create mode 100644 Services/Hosting/GcHeapLimit.cs
create mode 100644 tests/Craft.Tests/GcHeapLimitTests.cs
diff --git a/Services/Configuration/SkuProfile.cs b/Services/Configuration/SkuProfile.cs
index 83ce805..fb5048f 100644
--- a/Services/Configuration/SkuProfile.cs
+++ b/Services/Configuration/SkuProfile.cs
@@ -32,4 +32,13 @@ public class SkuProfile
/// Background worker pool size to apply when this profile matches.
public int BgPoolSize { get; set; }
+
+ ///
+ /// Optional GC heap hard limit, in MB, to apply when this profile matches. Omit or 0 = keep the
+ /// process baseline (typically the DOTNET_GCHeapHardLimit env var baked into the image for the
+ /// smallest tier). The env var is consumed by the CLR before any managed code runs, so this is
+ /// applied after the fact via — raising the limit is
+ /// always safe; a value the heap has already outgrown is refused and logged.
+ ///
+ public int? GCHeapHardLimitMB { get; set; }
}
diff --git a/Services/Hosting/GcHeapLimit.cs b/Services/Hosting/GcHeapLimit.cs
new file mode 100644
index 0000000..b1dbc54
--- /dev/null
+++ b/Services/Hosting/GcHeapLimit.cs
@@ -0,0 +1,65 @@
+using Craft.Configuration;
+
+namespace Craft.Hosting;
+
+///
+/// Host-tier GC heap sizing — the companion to .
+///
+/// The image bakes a conservative DOTNET_GCHeapHardLimit sized for the smallest supported
+/// tier, and the CLR consumes that env var before any managed code runs — a tier with more memory
+/// to give can only raise the limit after startup. .NET 8 provides exactly that hatch:
+/// under the GCHeapHardLimit key, then
+/// , which re-reads the limit configuration (AppContext data
+/// takes precedence over the startup env var) and applies it to the running GC.
+///
+///
+/// Best-effort under the same contract as pool sizing: any failure — including
+/// refusing a limit below what the heap has already
+/// committed — logs and keeps the baseline rather than taking the host down over a sizing hint.
+///
+///
+public static class GcHeapLimit
+{
+ ///
+ /// Applies from the matched profile, if it carries one.
+ ///
+ /// The profile matched, or null.
+ /// Sink for the operator-facing before/after line.
+ /// The actual limit change, injectable for tests; null = the real
+ /// AppContext + path.
+ /// when a limit was applied.
+ public static bool Apply(SkuProfile? profile, Action log, Action? refresh = null)
+ {
+ ArgumentNullException.ThrowIfNull(log);
+
+ var mb = profile?.GCHeapHardLimitMB ?? 0;
+ if (mb <= 0) return false;
+
+ refresh ??= SetAndRefresh;
+ var beforeMB = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
+ try
+ {
+ refresh((ulong)mb * 1024 * 1024);
+ var afterMB = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
+ log($"[System] GC heap hard limit set to {mb} MB by SkuProfile " +
+ $"(TotalAvailableMemory {beforeMB} MB -> {afterMB} MB)");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Same contract as pool sizing: a sizing hint must never prevent startup.
+ log($"[System] GC heap hard limit refresh to {mb} MB failed " +
+ $"({ex.GetType().Name}: {ex.Message}); keeping {beforeMB} MB");
+ return false;
+ }
+ }
+
+ /// Convenience overload writing to the console, like SkuProfileSelector.Apply(settings).
+ public static bool Apply(SkuProfile? profile) => Apply(profile, Console.WriteLine);
+
+ private static void SetAndRefresh(ulong bytes)
+ {
+ AppContext.SetData("GCHeapHardLimit", bytes);
+ GC.RefreshMemoryLimit();
+ }
+}
diff --git a/Services/Hosting/SkuProfileSelector.cs b/Services/Hosting/SkuProfileSelector.cs
index 8f6daf0..072060e 100644
--- a/Services/Hosting/SkuProfileSelector.cs
+++ b/Services/Hosting/SkuProfileSelector.cs
@@ -15,6 +15,10 @@ namespace Craft.Hosting;
/// Detection is best-effort by design: any failure logs and leaves the baseline sizes untouched
/// rather than taking the host down over a pool-sizing hint.
///
+///
+/// The returned profile may also carry a ; applying that
+/// is a process-wide side effect and lives in , not here.
+///
///
public static class SkuProfileSelector
{
diff --git a/Services/Program.cs b/Services/Program.cs
index 6cef08a..9cb7874 100644
--- a/Services/Program.cs
+++ b/Services/Program.cs
@@ -16,8 +16,10 @@
// Bind App section to CraftSettings
builder.Services.Configure(builder.Configuration.GetSection("App"));
-// Apply SkuProfiles override (host-tier pool sizing) before any consumer resolves the options
-builder.Services.PostConfigure(s => SkuProfileSelector.Apply(s));
+// Apply SkuProfiles override (host-tier pool sizing + GC heap limit) before any consumer resolves
+// the options — the heap limit lands via GC.RefreshMemoryLimit, so it must run before the worker
+// pools start growing the heap.
+builder.Services.PostConfigure(s => GcHeapLimit.Apply(SkuProfileSelector.Apply(s)));
// Also register a singleton accessor for non-DI contexts
builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value);
diff --git a/appsettings.example.jsonc b/appsettings.example.jsonc
index 632997b..693c0d4 100644
--- a/appsettings.example.jsonc
+++ b/appsettings.example.jsonc
@@ -153,13 +153,18 @@
// (e.g. "Basic", "PremiumV3"). Ignored when SkuEnv is empty.
// Cpu: compared to Environment.ProcessorCount (respects cgroup limits in
// Docker). Omit or 0 = match any CPU count.
+ // GCHeapHardLimitMB: optional GC heap hard limit (MB) for this tier. The
+ // DOTNET_GCHeapHardLimit env var is consumed before managed code runs,
+ // so this lands via GC.RefreshMemoryLimit at startup instead. Omit or
+ // 0 = keep the process baseline. Raising is always safe; a value the
+ // heap has already outgrown is refused and logged, keeping the baseline.
// First match wins. No match (or any parse failure) leaves the baseline above.
// Set IgnoreSkuProfiles: true to disable this entirely.
// "IgnoreSkuProfiles": false,
// "SkuProfiles": [
// { "SkuEnv": "WEBSITE_SKU", "Sku": "Basic", "Cpu": 1, "HttpPoolSize": 2, "BgPoolSize": 2 },
// { "SkuEnv": "WEBSITE_SKU", "Sku": "Basic", "Cpu": 2, "HttpPoolSize": 3, "BgPoolSize": 4 },
- // { "SkuEnv": "WEBSITE_SKU", "Sku": "Basic", "Cpu": 4, "HttpPoolSize": 4, "BgPoolSize": 4 },
+ // { "SkuEnv": "WEBSITE_SKU", "Sku": "Basic", "Cpu": 4, "HttpPoolSize": 4, "BgPoolSize": 4, "GCHeapHardLimitMB": 5120 },
// { "SkuEnv": "WEBSITE_SKU", "Sku": "PremiumV3", "Cpu": 2, "HttpPoolSize": 4, "BgPoolSize": 8 },
// { "Cpu": 8, "HttpPoolSize": 6, "BgPoolSize": 12 }
// ],
diff --git a/tests/Craft.Tests/GcHeapLimitTests.cs b/tests/Craft.Tests/GcHeapLimitTests.cs
new file mode 100644
index 0000000..29c4e85
--- /dev/null
+++ b/tests/Craft.Tests/GcHeapLimitTests.cs
@@ -0,0 +1,94 @@
+using Craft.Configuration;
+using Craft.Hosting;
+
+namespace Craft.Tests;
+
+///
+/// The GC heap hard limit rides on the same SkuProfile match as pool sizing, but unlike pool sizes
+/// it cannot be set through configuration the runtime reads at startup — it has to land through
+/// AppContext + after the CLR is already up. Getting this wrong
+/// either leaves a large tier capped at the smallest tier's heap, or (worse) turns a sizing hint
+/// into a startup failure.
+///
+public class GcHeapLimitTests
+{
+ [Fact]
+ public void NoMatchedProfile_IsANoOp()
+ {
+ var logs = new List();
+
+ var applied = GcHeapLimit.Apply(null, logs.Add, _ => throw new InvalidOperationException("must not be called"));
+
+ Assert.False(applied);
+ Assert.Empty(logs); // like unused SkuProfiles: don't log on every start
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void ProfileWithoutALimit_IsANoOp(int? mb)
+ {
+ var profile = new SkuProfile { HttpPoolSize = 2, BgPoolSize = 2, GCHeapHardLimitMB = mb };
+ var logs = new List();
+
+ var applied = GcHeapLimit.Apply(profile, logs.Add, _ => throw new InvalidOperationException("must not be called"));
+
+ Assert.False(applied);
+ Assert.Empty(logs);
+ }
+
+ [Fact]
+ public void ConfiguredLimit_IsAppliedInBytes_AndLogged()
+ {
+ var profile = new SkuProfile { GCHeapHardLimitMB = 5120 };
+ var logs = new List();
+ ulong? requested = null;
+
+ var applied = GcHeapLimit.Apply(profile, logs.Add, bytes => requested = bytes);
+
+ Assert.True(applied);
+ Assert.Equal(5120UL * 1024 * 1024, requested);
+ Assert.Contains(logs, l => l.Contains("GC heap hard limit set to 5120 MB", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void RefreshRefusingTheLimit_LogsAndKeepsTheBaseline()
+ {
+ // GC.RefreshMemoryLimit throws when the new limit is below what the heap has already
+ // committed. That must stay a logged hint, never a startup failure.
+ var profile = new SkuProfile { GCHeapHardLimitMB = 1 };
+ var logs = new List();
+
+ var applied = GcHeapLimit.Apply(profile, logs.Add,
+ _ => throw new InvalidOperationException("RefreshMemoryLimit failed"));
+
+ Assert.False(applied);
+ Assert.Contains(logs, l => l.Contains("GC heap hard limit refresh to 1 MB failed", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void RealRefresh_ChangesTheProcessHeapLimit()
+ {
+ // Proves the actual mechanism (AppContext.SetData + GC.RefreshMemoryLimit) works, not just
+ // our plumbing around it. Target slightly below the current budget: far above anything the
+ // test host has committed, so it cannot destabilize parallel tests, while still being an
+ // observable change. Restored afterwards (raising back is always allowed).
+ var before = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
+ if (before < 2L * 1024 * 1024 * 1024) return; // tiny CI container — not worth the risk
+
+ var targetMB = (int)(before / (1024 * 1024)) - 128;
+ try
+ {
+ var applied = GcHeapLimit.Apply(new SkuProfile { GCHeapHardLimitMB = targetMB }, _ => { });
+
+ Assert.True(applied);
+ Assert.Equal((long)targetMB * 1024 * 1024, GC.GetGCMemoryInfo().TotalAvailableMemoryBytes);
+ }
+ finally
+ {
+ AppContext.SetData("GCHeapHardLimit", (ulong)before);
+ GC.RefreshMemoryLimit();
+ }
+ }
+}
From adbf6af10dd21c7f5fefc4af2b154fdbab625f60 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 28 Aug 2026 22:09:43 +0800
Subject: [PATCH 05/17] fix(orchestration): keep a background OOM from stalling
the run and false-failing jobs
Under a pegged GC hard limit, an OutOfMemoryException thrown while logging inside
the JobQueuePump and JobManager catch-all blocks escaped ExecuteAsync. With the
host's default BackgroundServiceExceptionBehavior.StopHost that faults the
service and restarts the container mid-run: dispatch stops with work still
queued, and the pending backlog waits for the restart to reclaim its leases.
Guard those two log calls the way BackgroundTaskLimiter already guards its own
("logging is never worth the loop") so an allocation failure in logging can no
longer take the host down.
Separately, jobs still in flight when a shutdown began finished their work and
persisted their data, then threw ObjectDisposedException enqueueing their
terminal status because OrchestratorStatusWriter._signal was already disposed --
so a task that fully succeeded was recorded as Failed. Route every drain-loop
wake through a guarded Signal() that drops the wake once disposed. A lost
coalesced status wake on the way down is harmless; a succeeded task marked Failed
is not.
---
Services/Orchestration/JobManager.cs | 8 +++++-
Services/Orchestration/JobQueuePump.cs | 9 +++++-
.../Orchestration/OrchestratorStatusWriter.cs | 28 +++++++++++++++----
3 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/Services/Orchestration/JobManager.cs b/Services/Orchestration/JobManager.cs
index 154afb3..9ab490e 100644
--- a/Services/Orchestration/JobManager.cs
+++ b/Services/Orchestration/JobManager.cs
@@ -320,7 +320,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
// Anything else — a throw out of the queue lock, the limiter or the semaphore — used to
// escape ExecuteAsync and silently end dispatch for the life of the process. One bad
// iteration must not stop every future job.
- _logger.LogError(ex, "[JobManager] Dispatch iteration failed; continuing");
+ //
+ // The log is guarded too: under a pegged GC hard limit LogError can itself throw OOM, and
+ // this catch is the last frame before ExecuteAsync — an escape here faults the loop and,
+ // with the host's default StopHost behaviour, takes the container down mid-run. A failed
+ // log is never worth dispatch. (BackgroundTaskLimiter guards its logs for the same reason.)
+ try { _logger.LogError(ex, "[JobManager] Dispatch iteration failed; continuing"); }
+ catch { /* logging is never worth the loop */ }
}
finally
{
diff --git a/Services/Orchestration/JobQueuePump.cs b/Services/Orchestration/JobQueuePump.cs
index 1562552..bbfa919 100644
--- a/Services/Orchestration/JobQueuePump.cs
+++ b/Services/Orchestration/JobQueuePump.cs
@@ -92,7 +92,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// One bad cycle must not end the pump; the next tick tries again. A pump that dies
// silently would look exactly like an empty queue.
- _logger.LogError(ex, "[JobQueuePump] Cycle failed; continuing");
+ //
+ // The log itself is guarded: under a pegged GC hard limit even LogError allocates and can
+ // throw OOM, and this catch is the last frame before ExecuteAsync — an escape here faults
+ // the service and, with the host's default StopHost behaviour, restarts the container
+ // mid-run. A failed log is never worth the pump. (BackgroundTaskLimiter guards its logs
+ // past the point of commitment for the same reason.)
+ try { _logger.LogError(ex, "[JobQueuePump] Cycle failed; continuing"); }
+ catch { /* logging is never worth the loop */ }
}
// Idle means this pump has nothing: it claimed nothing AND holds nothing. Note what is
diff --git a/Services/Orchestration/OrchestratorStatusWriter.cs b/Services/Orchestration/OrchestratorStatusWriter.cs
index 10dd3e7..22e687e 100644
--- a/Services/Orchestration/OrchestratorStatusWriter.cs
+++ b/Services/Orchestration/OrchestratorStatusWriter.cs
@@ -41,6 +41,10 @@ public sealed class OrchestratorStatusWriter : IDisposable
private readonly CancellationTokenSource _cts = new();
private readonly Task _drainLoop;
+ /// Set first in so a status enqueue racing shutdown drops its wake
+ /// instead of throwing ObjectDisposedException into a job that already succeeded.
+ private volatile bool _disposed;
+
public bool Enabled => _enabled;
public OrchestratorStatusWriter(OrchestratorTableStore store, ILogger logger, CraftSettings settings)
@@ -84,7 +88,7 @@ public async Task MarkRunningAsync(string runName, OrchestratorTaskItem task, Ca
_pendingTasks[Key(runName, task.Id)] = Snap(runName, task);
barrier = _barrier.Task;
}
- _signal.Release();
+ Signal();
// Bounded. This wait sits between dispatch and worker checkout while holding a JobManager slot,
// so waiting forever converts a slow flush into a whole-host outage — observed in production as
@@ -135,7 +139,7 @@ public void QueueTask(string runName, OrchestratorTaskItem task)
{
if (!_enabled) { _ = _store.UpsertTaskAsync(runName, task); return; }
lock (_lock) { _pendingTasks[Key(runName, task.Id)] = Snap(runName, task); }
- _signal.Release();
+ Signal();
}
/// Queue a run's status — non-blocking, coalesced, flushed by the drain loop.
@@ -143,7 +147,7 @@ public void QueueRun(OrchestratorRun run)
{
if (!_enabled) { _ = _store.UpsertRunAsync(run); return; }
lock (_lock) { _pendingRuns[run.Name] = run; }
- _signal.Release();
+ Signal();
}
/// Flush all currently-pending writes and await their persistence. Call before finalizing a run
@@ -153,7 +157,7 @@ public async Task FlushAsync(CancellationToken ct = default)
if (!_enabled) return;
Task barrier;
lock (_lock) { barrier = _barrier.Task; }
- _signal.Release();
+ Signal();
// Bounded for the same reason as the Running marker: this is awaited by FinalizeRunAsync, and an
// unbounded wait meant no run could finalize while a flush was stuck. Requeue-on-failure means a
@@ -296,14 +300,28 @@ private void Requeue(Dictionary tasks, Dictionary 0)
{
- _signal.Release(); // make sure the next flush actually runs
+ Signal(); // make sure the next flush actually runs
_logger.LogWarning("[Orchestrator] Requeued {Count} un-persisted status writes across {Runs} runs",
restored, retry.Count);
}
}
+ ///
+ /// Wake the drain loop. Guarded so a status enqueue that races — an in-flight
+ /// job finishing after shutdown began — drops the wake instead of throwing ObjectDisposedException.
+ /// By the time a task queues its terminal status its data is already persisted, so a lost coalesced
+ /// wake on the way down is harmless; a task falsely marked Failed because the release threw is not.
+ ///
+ private void Signal()
+ {
+ if (_disposed) return;
+ try { _signal.Release(); }
+ catch (ObjectDisposedException) { /* shutting down; the drain loop has already stopped */ }
+ }
+
public void Dispose()
{
+ _disposed = true;
_cts.Cancel();
try { _drainLoop.Wait(TimeSpan.FromSeconds(5)); } catch { /* best effort final drain */ }
_cts.Dispose();
From ed337c04e3b063e7d32a61a941573b174570ab0f Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 28 Aug 2026 22:11:23 +0800
Subject: [PATCH 06/17] feat(telemetry): add startup usage reporting
Introduce opt-in startup telemetry with configurable endpoint, app ID, timeouts, and jittered delays. Register a hosted service that emits one guarded boot report per instance using persisted storage state, and add version stamping plus native catalog injection so reports include meaningful build and surface metadata.
---
Directory.Build.props | 8 +
Services/Configuration/CraftSettings.cs | 3 +
Services/Configuration/TelemetrySettings.cs | 41 +++
.../Hosting/CraftHostBuilderExtensions.cs | 10 +
Services/Program.cs | 3 +
Services/Telemetry/StartupReport.cs | 22 ++
Services/Telemetry/StartupTelemetryService.cs | 251 ++++++++++++++++++
7 files changed, 338 insertions(+)
create mode 100644 Services/Configuration/TelemetrySettings.cs
create mode 100644 Services/Telemetry/StartupReport.cs
create mode 100644 Services/Telemetry/StartupTelemetryService.cs
diff --git a/Directory.Build.props b/Directory.Build.props
index 411c957..70a6ee8 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -55,6 +55,14 @@
CyberDrainhttps://github.com/CyberDrain/CRAFTgit
+
+ 0.0.0-dev
diff --git a/Services/Configuration/CraftSettings.cs b/Services/Configuration/CraftSettings.cs
index d429d8a..897d0ae 100644
--- a/Services/Configuration/CraftSettings.cs
+++ b/Services/Configuration/CraftSettings.cs
@@ -106,4 +106,7 @@ public class CraftSettings
/// Realtime SSE channel (/.craft/events). See .
public RealtimeSettings Realtime { get; set; } = new();
+
+ /// Startup phone-home usage telemetry. Off by default. See .
+ public TelemetrySettings Telemetry { get; set; } = new();
}
diff --git a/Services/Configuration/TelemetrySettings.cs b/Services/Configuration/TelemetrySettings.cs
new file mode 100644
index 0000000..aa180a4
--- /dev/null
+++ b/Services/Configuration/TelemetrySettings.cs
@@ -0,0 +1,41 @@
+namespace Craft.Configuration;
+
+///
+/// Startup phone-home telemetry (App:Telemetry:*). One usage report per process start, storm
+/// guarded so a crash loop cannot flood the ingest. See StartupTelemetryService.
+///
+///
+/// Off by default: nothing is sent until an operator both enables it and supplies an
+/// and an . The CRAFT_TELEMETRY_OPTOUT=1 environment
+/// variable forces it off regardless of configuration.
+///
+///
+public class TelemetrySettings
+{
+ /// Master switch. Off by default (privacy posture is an operator decision).
+ public bool Enabled { get; set; }
+
+ /// Ingest URL, e.g. https://reporting.example.com/API/TelemetryIngest. No send without it.
+ public string? Endpoint { get; set; }
+
+ /// Application id for this image (cipp, geoipdb, …). No send without it.
+ public string? AppId { get; set; }
+
+ /// Storm-guard floor: at most one report per this many hours per instance. Floored at 1.
+ public int MinIntervalHours { get; set; } = 6;
+
+ /// Outbound POST timeout in seconds. No retry — the next boot is the retry.
+ public int TimeoutSeconds { get; set; } = 10;
+
+ /// Lower bound of the jittered startup delay, in seconds.
+ public int MinStartupDelaySeconds { get; set; } = 60;
+
+ /// Upper bound of the jittered startup delay, in seconds.
+ public int MaxStartupDelaySeconds { get; set; } = 300;
+
+ /// Table holding the per-instance storm-guard state (instanceId, lastSentUtc).
+ public string GuardTable { get; set; } = "CraftTelemetryGuard";
+
+ /// Optional shared token sent as X-Telemetry-Token to a token-gated ingest.
+ public string? Token { get; set; }
+}
diff --git a/Services/Hosting/CraftHostBuilderExtensions.cs b/Services/Hosting/CraftHostBuilderExtensions.cs
index e649528..8e7b5ab 100644
--- a/Services/Hosting/CraftHostBuilderExtensions.cs
+++ b/Services/Hosting/CraftHostBuilderExtensions.cs
@@ -11,6 +11,7 @@
using Craft.Services;
using Craft.Setup;
using Craft.Storage;
+using Craft.Telemetry;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Logging.Console;
@@ -268,6 +269,15 @@ public static IServiceCollection AddCraftServices(this IServiceCollection servic
return new ContainerHealthMonitor(logger, health);
});
+ // Startup telemetry emitter. Registered on EVERY node (not just Background) so a frontend+api
+ // node still reports; the service self-gates on role, config, and the persisted storm guard,
+ // and fires at most once per process. The roles are made injectable here for it to report
+ // host.roles, and IHttpClientFactory for the single outbound POST.
+ services.AddSingleton(roles);
+ services.AddHttpClient();
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+
return services;
}
diff --git a/Services/Program.cs b/Services/Program.cs
index 9cb7874..132a665 100644
--- a/Services/Program.cs
+++ b/Services/Program.cs
@@ -75,6 +75,9 @@
LoggerFactory.Create(b => b.AddSimpleConsole()).CreateLogger("Craft.Endpoints"));
builder.Services.AddCraftServices(roles);
+// The discovered catalog, injectable so the startup telemetry emitter can report a native route count
+// without re-scanning. Registered even when empty so the dependency always resolves.
+builder.Services.AddSingleton(nativeCatalog);
if (!nativeCatalog.IsEmpty)
builder.Services.AddNativeEndpoints(nativeCatalog, builder.Configuration);
builder.Services.AddCraftRateLimiter(craftSettings);
diff --git a/Services/Telemetry/StartupReport.cs b/Services/Telemetry/StartupReport.cs
new file mode 100644
index 0000000..c1a59e1
--- /dev/null
+++ b/Services/Telemetry/StartupReport.cs
@@ -0,0 +1,22 @@
+namespace Craft.Telemetry;
+
+// The boot-inventory envelope. Serialized with web defaults (camelCase). The ingest is tolerant, so
+// this stays a small, stable core; new report types ride the same surface via reportType.
+internal sealed record StartupReport(
+ int SchemaVersion,
+ string ReportType,
+ string ReportId,
+ string InstanceId,
+ DateTimeOffset SentUtc,
+ AppInfo App,
+ CraftInfo Craft,
+ HostInfo Host,
+ SurfaceInfo Surface);
+
+internal sealed record AppInfo(string Id, string? Version, string? Commit, string? ImageTag);
+
+internal sealed record CraftInfo(string? Version);
+
+internal sealed record HostInfo(string? Sku, string[] Roles, string Platform, string? Region);
+
+internal sealed record SurfaceInfo(int RouteCount, int ScheduledTaskCount);
diff --git a/Services/Telemetry/StartupTelemetryService.cs b/Services/Telemetry/StartupTelemetryService.cs
new file mode 100644
index 0000000..7e19a5b
--- /dev/null
+++ b/Services/Telemetry/StartupTelemetryService.cs
@@ -0,0 +1,251 @@
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using Craft.Configuration;
+using Craft.Endpoints;
+using Craft.Hosting;
+using Craft.Orchestration;
+using Craft.PowerShellHost;
+using Craft.Storage;
+using Microsoft.Extensions.Options;
+
+namespace Craft.Telemetry;
+
+///
+/// Fires ONCE per process start: after readiness and a jittered delay, POSTs a small usage
+/// "boot inventory" to a reporting ingest, storm-guarded so a crash loop cannot flood it.
+///
+///
+/// Runtime-level, so every Craft-based app reports with zero app work. It must never affect the host:
+/// every failure path logs at debug/info and swallows. The storm guard is persisted in table storage
+/// (outside the container, so it survives crash loops) and is fail-closed — if the guard state cannot
+/// be read, nothing is sent.
+///
+///
+internal sealed class StartupTelemetryService(
+ IOptions settings,
+ ICraftTableStore store,
+ StorageHealthMonitor storageHealth,
+ ScriptRepository scriptRepo,
+ SchedulerService scheduler,
+ NativeEndpointCatalog nativeCatalog,
+ CraftRoles roles,
+ IHttpClientFactory httpFactory,
+ IHostApplicationLifetime lifetime,
+ ILogger logger) : BackgroundService
+{
+ private static readonly JsonSerializerOptions s_json = new(JsonSerializerDefaults.Web);
+ private const string GuardPartition = "guard";
+ private const string ColInstanceId = "InstanceId";
+ private const string ColLastSent = "LastSentUtc";
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ await RunAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ // Shutting down before the delay elapsed — the next boot is the retry.
+ }
+ catch (Exception ex)
+ {
+ // The emitter must never take the host down. Swallow everything.
+ logger.LogDebug(ex, "[Telemetry] Startup emitter failed; swallowed");
+ }
+ }
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ var t = settings.Value.Telemetry;
+
+ if (OptOut())
+ {
+ logger.LogInformation("[Telemetry] CRAFT_TELEMETRY_OPTOUT is set — not sending");
+ return;
+ }
+ if (!t.Enabled)
+ {
+ logger.LogDebug("[Telemetry] Disabled (App:Telemetry:Enabled=false)");
+ return;
+ }
+
+ var appId = t.AppId?.Trim();
+ if (string.IsNullOrEmpty(appId) || string.IsNullOrWhiteSpace(t.Endpoint))
+ {
+ logger.LogInformation("[Telemetry] Enabled but AppId/Endpoint unset — not sending");
+ return;
+ }
+
+ // The guard lives in storage, which a frontend-only node never resolves.
+ if (!(roles.Http || roles.Background))
+ {
+ logger.LogDebug("[Telemetry] Node carries no storage role — not sending");
+ return;
+ }
+
+ await WaitForStartedAsync(ct);
+ if (!await storageHealth.WaitUntilReadyAsync(TimeSpan.FromSeconds(60), ct))
+ {
+ logger.LogInformation("[Telemetry] Storage not ready — fail-closed, not sending");
+ return;
+ }
+
+ // Read or mint the storm-guard row. Fail-closed: never emit unguarded.
+ var guardKey = TableKeys.Sanitize($"{appId}:{SiteName()}");
+ GuardState guard;
+ try
+ {
+ guard = await ReadOrMintGuardAsync(t.GuardTable, guardKey, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation(ex, "[Telemetry] Guard state unreadable — fail-closed, not sending");
+ return;
+ }
+
+ // Storm guard: at most one report per MinIntervalHours per instance.
+ var minInterval = TimeSpan.FromHours(Math.Max(1, t.MinIntervalHours));
+ if (guard.LastSentUtc is { } last && DateTimeOffset.UtcNow - last < minInterval)
+ {
+ logger.LogDebug("[Telemetry] Within the min-interval window — skipping this boot");
+ return;
+ }
+
+ // Jittered, deterministic-per-instance delay. A fast crash loop dies inside this window and
+ // never reaches the send; it also keeps telemetry out of the cold-start window on Basic SKUs.
+ await Task.Delay(JitterDelay(guard.InstanceId, t), ct);
+
+ var report = BuildReport(appId, guard.InstanceId);
+ if (await PostAsync(report, t, ct))
+ {
+ // lastSentUtc advances ONLY on a successful send, so a failed send never silences the instance.
+ await WriteGuardAsync(t.GuardTable, guardKey, guard.InstanceId, DateTimeOffset.UtcNow, ct);
+ logger.LogInformation("[Telemetry] Startup report sent for {AppId}", appId);
+ }
+ }
+
+ private async Task WaitForStartedAsync(CancellationToken ct)
+ {
+ if (lifetime.ApplicationStarted.IsCancellationRequested) return;
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var startedReg = lifetime.ApplicationStarted.Register(() => tcs.TrySetResult());
+ using var ctReg = ct.Register(() => tcs.TrySetCanceled(ct));
+ await tcs.Task;
+ }
+
+ private async Task ReadOrMintGuardAsync(string table, string key, CancellationToken ct)
+ {
+ await store.EnsureTableAsync(table, ct);
+
+ var row = await store.GetAsync(table, GuardPartition, key, ct);
+ if (row is not null && row.GetString(ColInstanceId) is { Length: > 0 } existing)
+ return new GuardState(existing, row.GetDateTimeOffset(ColLastSent));
+
+ // First ever run for this key: mint a stable instance id and persist it (no lastSent yet).
+ var minted = Guid.NewGuid().ToString();
+ await WriteGuardAsync(table, key, minted, lastSent: null, ct);
+ return new GuardState(minted, null);
+ }
+
+ private async Task WriteGuardAsync(
+ string table, string key, string instanceId, DateTimeOffset? lastSent, CancellationToken ct)
+ {
+ var row = new StoreRow(GuardPartition, key);
+ row[ColInstanceId] = instanceId;
+ if (lastSent is { } value) row[ColLastSent] = value;
+ await store.UpsertAsync(table, row, ct);
+ }
+
+ private StartupReport BuildReport(string appId, string instanceId)
+ {
+ var roleNames = new List(3);
+ if (roles.Frontend) roleNames.Add("frontend");
+ if (roles.Http) roleNames.Add("api");
+ if (roles.Background) roleNames.Add("background");
+
+ // Counts only — no route names, no per-route hits.
+ var routeCount = nativeCatalog.Endpoints.Count + scriptRepo.HttpRoutes.Count;
+ var taskCount = scheduler.Tasks.Count;
+
+ return new StartupReport(
+ SchemaVersion: 1,
+ ReportType: "startup",
+ ReportId: Guid.NewGuid().ToString(),
+ InstanceId: instanceId,
+ SentUtc: DateTimeOffset.UtcNow,
+ App: new AppInfo(appId, Env("APP_VERSION"), Env("COMMIT_SHA"), Env("IMAGE_TAG")),
+ Craft: new CraftInfo(CraftVersion()),
+ Host: new HostInfo(Env("WEBSITE_SKU"), roleNames.ToArray(), Platform(), Region: null),
+ Surface: new SurfaceInfo(routeCount, taskCount));
+ }
+
+ private async Task PostAsync(StartupReport report, TelemetrySettings t, CancellationToken ct)
+ {
+ try
+ {
+ var client = httpFactory.CreateClient("craft-telemetry");
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, t.TimeoutSeconds)));
+
+ var json = JsonSerializer.Serialize(report, s_json);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var request = new HttpRequestMessage(HttpMethod.Post, t.Endpoint) { Content = content };
+ if (!string.IsNullOrEmpty(t.Token))
+ request.Headers.TryAddWithoutValidation("X-Telemetry-Token", t.Token);
+
+ using var response = await client.SendAsync(request, cts.Token);
+ if (response.IsSuccessStatusCode) return true;
+
+ logger.LogDebug("[Telemetry] Ingest returned HTTP {Status}", (int)response.StatusCode);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "[Telemetry] POST failed; the next boot is the retry");
+ return false;
+ }
+ }
+
+ private static TimeSpan JitterDelay(string instanceId, TelemetrySettings t)
+ {
+ var min = Math.Max(0, t.MinStartupDelaySeconds);
+ var max = Math.Max(min, t.MaxStartupDelaySeconds);
+ var span = (uint)(max - min) + 1;
+ var seconds = min + (int)((uint)StableHash(instanceId) % span);
+ return TimeSpan.FromSeconds(seconds);
+ }
+
+ // string.GetHashCode is randomized per process, so roll a stable one for a deterministic spread.
+ private static int StableHash(string value)
+ {
+ unchecked
+ {
+ var hash = 17;
+ foreach (var c in value) hash = (hash * 31) + c;
+ return hash;
+ }
+ }
+
+ private static string? CraftVersion() =>
+ typeof(StartupTelemetryService).Assembly
+ .GetCustomAttribute()?.InformationalVersion;
+
+ private static string Platform() => Env("WEBSITE_SITE_NAME") is not null ? "appservice" : "container";
+
+ private static string SiteName() => Env("WEBSITE_SITE_NAME") ?? "self";
+
+ private static bool OptOut()
+ {
+ var value = Environment.GetEnvironmentVariable("CRAFT_TELEMETRY_OPTOUT");
+ return string.Equals(value, "1", StringComparison.Ordinal)
+ || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string? Env(string name) =>
+ Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null;
+
+ private sealed record GuardState(string InstanceId, DateTimeOffset? LastSentUtc);
+}
From 7d1653c3e03e65bc6a1663c005b07564f02ad8ce Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:55:33 +0800
Subject: [PATCH 07/17] fix(orchestration): guard the run-status timer callback
against crashing the host
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A System.Threading.Timer callback that throws takes the whole process down. The
per-run 60s maintenance tick (LogRunStatus / RedrivePendingTasks / CheckRunCompletion)
had no guard, so a transient error — a dependency disposed during shutdown, a race
on run state — would crash the host instead of being logged and retried on the next
tick. Wrap the callback in try/catch (null-safe logger).
---
Services/Orchestration/OrchestratorService.cs | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/Services/Orchestration/OrchestratorService.cs b/Services/Orchestration/OrchestratorService.cs
index 53c8f8c..9f77b3f 100644
--- a/Services/Orchestration/OrchestratorService.cs
+++ b/Services/Orchestration/OrchestratorService.cs
@@ -792,9 +792,19 @@ private async Task DispatchPendingTasksAsync(OrchestratorRun run, string taskPat
// permanent, which is a worse failure than the premature finalize it exists to prevent.
var timer = new Timer(_ =>
{
- LogRunStatus(run);
- RedrivePendingTasks(run);
- lock (_lock) { CheckRunCompletion(run); }
+ // A System.Threading.Timer callback that throws crashes the process. This periodic
+ // maintenance tick must never take the host down on a transient error — a dependency
+ // disposed during shutdown, a race on run state — so it logs and waits for the next tick.
+ try
+ {
+ LogRunStatus(run);
+ RedrivePendingTasks(run);
+ lock (_lock) { CheckRunCompletion(run); }
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(ex, "[Scheduler] Run status tick failed for {Name}", run?.Name);
+ }
},
null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
if (!_runStatusTimers.TryAdd(run.Name, timer))
From 363921ff42e116f5ef03958796cdb267904b81ed Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:16:50 +0800
Subject: [PATCH 08/17] feat(hosting): add CRAFT_GC_HEAP_LIMIT_MB per-instance
GC heap override
The SkuProfile GC limit lands through AppContext + GC.RefreshMemoryLimit,
which the runtime treats as precedence over DOTNET_GCHeapHardLimit (it won't
re-read that env var on refresh). So a fleet-wide profile value silently
countermands a heap limit an operator hand-set on a single instance.
Add CRAFT_GC_HEAP_LIMIT_MB as the per-instance escape hatch, mirroring
CRAFT_API_CONCURRENCY_LIMIT and CRAFT_HTTP_QUEUE_TIMEOUT: when set it wins over
the matched profile. A positive value sets the cap; 0 disables it entirely,
refreshing to the container's own memory allowance. Unset/negative/unparseable
defers to the profile (unchanged behaviour). Disabling only ever raises the
limit, so it cannot trip RefreshMemoryLimit's below-committed-heap guard; any
refusal logs and keeps the baseline, same best-effort contract as pool sizing.
---
Services/Configuration/SkuProfile.cs | 5 ++
Services/Hosting/GcHeapLimit.cs | 60 +++++++++++++++++---
appsettings.example.jsonc | 3 +
tests/Craft.Tests/GcHeapLimitTests.cs | 81 ++++++++++++++++++++++++++-
4 files changed, 139 insertions(+), 10 deletions(-)
diff --git a/Services/Configuration/SkuProfile.cs b/Services/Configuration/SkuProfile.cs
index fb5048f..fe243b7 100644
--- a/Services/Configuration/SkuProfile.cs
+++ b/Services/Configuration/SkuProfile.cs
@@ -39,6 +39,11 @@ public class SkuProfile
/// smallest tier). The env var is consumed by the CLR before any managed code runs, so this is
/// applied after the fact via — raising the limit is
/// always safe; a value the heap has already outgrown is refused and logged.
+ ///
+ /// Per-instance override: the CRAFT_GC_HEAP_LIMIT_MB env var wins over this value (a positive
+ /// value sets the cap; 0 disables the cap entirely), so an operator can hand-tune one host
+ /// without editing the fleet-wide profile list.
+ ///
///
public int? GCHeapHardLimitMB { get; set; }
}
diff --git a/Services/Hosting/GcHeapLimit.cs b/Services/Hosting/GcHeapLimit.cs
index b1dbc54..14ae7a0 100644
--- a/Services/Hosting/GcHeapLimit.cs
+++ b/Services/Hosting/GcHeapLimit.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Craft.Configuration;
namespace Craft.Hosting;
@@ -10,7 +11,16 @@ namespace Craft.Hosting;
/// to give can only raise the limit after startup. .NET 8 provides exactly that hatch:
/// under the GCHeapHardLimit key, then
/// , which re-reads the limit configuration (AppContext data
-/// takes precedence over the startup env var) and applies it to the running GC.
+/// takes precedence over the startup env var — the runtime deliberately won't re-read that var) and
+/// applies it to the running GC.
+///
+///
+/// Because that AppContext write overrides DOTNET_GCHeapHardLimit, a fleet-wide profile value
+/// would otherwise silently countermand a limit an operator hand-set on a single instance. The
+/// CRAFT_GC_HEAP_LIMIT_MB env var is the per-instance escape hatch that restores that control,
+/// following the same pattern as CRAFT_API_CONCURRENCY_LIMIT and CRAFT_HTTP_QUEUE_TIMEOUT:
+/// when set it wins over the matched profile. A value of 0 disables the cap entirely (refresh to
+/// the container's own memory allowance, discarding the baked limit).
///
///
/// Best-effort under the same contract as pool sizing: any failure — including
@@ -21,19 +31,48 @@ namespace Craft.Hosting;
public static class GcHeapLimit
{
///
- /// Applies from the matched profile, if it carries one.
+ /// Applies a GC heap hard limit: the CRAFT_GC_HEAP_LIMIT_MB env override if set, otherwise
+ /// from the matched profile.
///
/// The profile matched, or null.
/// Sink for the operator-facing before/after line.
/// The actual limit change, injectable for tests; null = the real
/// AppContext + path.
- /// when a limit was applied.
- public static bool Apply(SkuProfile? profile, Action log, Action? refresh = null)
+ /// Reader for CRAFT_GC_HEAP_LIMIT_MB, injectable for tests; null =
+ /// the real process environment.
+ /// when a limit change was applied.
+ public static bool Apply(SkuProfile? profile, Action log, Action? refresh = null,
+ Func? readEnv = null)
{
ArgumentNullException.ThrowIfNull(log);
- var mb = profile?.GCHeapHardLimitMB ?? 0;
- if (mb <= 0) return false;
+ // Per-instance escape hatch. CRAFT_GC_HEAP_LIMIT_MB, when it parses to a non-negative integer,
+ // wins over the fleet-wide profile — same shape as ResolvedApiConcurrencyLimit / ResolveHttpQueueTimeout.
+ // > 0 : set that many MB, overriding the profile.
+ // 0 : disable the cap entirely — refresh to the container's own memory allowance, discarding
+ // the image-baked DOTNET_GCHeapHardLimit. Distinct from a profile's 0/null, which means
+ // "no opinion, keep the baseline". Removing the cap only ever raises the limit, so it
+ // cannot trip RefreshMemoryLimit's "below committed heap" guard.
+ // unset / negative / unparseable : defer to the profile (original behaviour).
+ readEnv ??= () => Environment.GetEnvironmentVariable("CRAFT_GC_HEAP_LIMIT_MB");
+ var envMB = int.TryParse(readEnv(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var e) && e >= 0
+ ? e
+ : (int?)null;
+
+ int mb;
+ string source;
+ if (envMB is int fromEnv)
+ {
+ mb = fromEnv;
+ source = "CRAFT_GC_HEAP_LIMIT_MB";
+ }
+ else
+ {
+ mb = profile?.GCHeapHardLimitMB ?? 0;
+ source = "SkuProfile";
+ // Profile 0/null/negative = keep the process baseline, silently (like unused SkuProfiles).
+ if (mb <= 0) return false;
+ }
refresh ??= SetAndRefresh;
var beforeMB = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
@@ -41,14 +80,17 @@ public static bool Apply(SkuProfile? profile, Action log, Action?
{
refresh((ulong)mb * 1024 * 1024);
var afterMB = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
- log($"[System] GC heap hard limit set to {mb} MB by SkuProfile " +
- $"(TotalAvailableMemory {beforeMB} MB -> {afterMB} MB)");
+ var what = mb == 0
+ ? $"GC heap hard limit disabled by {source} — deferring to container/physical memory"
+ : $"GC heap hard limit set to {mb} MB by {source}";
+ log($"[System] {what} (TotalAvailableMemory {beforeMB} MB -> {afterMB} MB)");
return true;
}
catch (Exception ex)
{
// Same contract as pool sizing: a sizing hint must never prevent startup.
- log($"[System] GC heap hard limit refresh to {mb} MB failed " +
+ var target = mb == 0 ? "disable" : $"{mb} MB";
+ log($"[System] GC heap hard limit change ({target}) by {source} failed " +
$"({ex.GetType().Name}: {ex.Message}); keeping {beforeMB} MB");
return false;
}
diff --git a/appsettings.example.jsonc b/appsettings.example.jsonc
index 693c0d4..3219f20 100644
--- a/appsettings.example.jsonc
+++ b/appsettings.example.jsonc
@@ -158,6 +158,9 @@
// so this lands via GC.RefreshMemoryLimit at startup instead. Omit or
// 0 = keep the process baseline. Raising is always safe; a value the
// heap has already outgrown is refused and logged, keeping the baseline.
+ // Per-instance override: CRAFT_GC_HEAP_LIMIT_MB wins over this (a positive
+ // value sets the cap; 0 disables the cap entirely, deferring to container
+ // memory) — hand-tune one host without touching the fleet-wide profiles.
// First match wins. No match (or any parse failure) leaves the baseline above.
// Set IgnoreSkuProfiles: true to disable this entirely.
// "IgnoreSkuProfiles": false,
diff --git a/tests/Craft.Tests/GcHeapLimitTests.cs b/tests/Craft.Tests/GcHeapLimitTests.cs
index 29c4e85..5566ae9 100644
--- a/tests/Craft.Tests/GcHeapLimitTests.cs
+++ b/tests/Craft.Tests/GcHeapLimitTests.cs
@@ -64,7 +64,7 @@ public void RefreshRefusingTheLimit_LogsAndKeepsTheBaseline()
_ => throw new InvalidOperationException("RefreshMemoryLimit failed"));
Assert.False(applied);
- Assert.Contains(logs, l => l.Contains("GC heap hard limit refresh to 1 MB failed", StringComparison.Ordinal));
+ Assert.Contains(logs, l => l.Contains("GC heap hard limit change (1 MB) by SkuProfile failed", StringComparison.Ordinal));
}
[Fact]
@@ -91,4 +91,83 @@ public void RealRefresh_ChangesTheProcessHeapLimit()
GC.RefreshMemoryLimit();
}
}
+
+ // ── CRAFT_GC_HEAP_LIMIT_MB: the per-instance escape hatch ─────────────────────────────────────────
+ // Restores the control a fleet-wide profile would otherwise take away: the profile writes the limit
+ // through AppContext, which overrides a hand-set DOTNET_GCHeapHardLimit, so an operator needs a way
+ // back in without editing the shared profile list. readEnv is injected here to keep the process
+ // environment untouched, exactly as refresh is injected to keep the real GC untouched.
+
+ [Fact]
+ public void EnvOverride_WinsOverProfile()
+ {
+ var profile = new SkuProfile { GCHeapHardLimitMB = 5120 };
+ var logs = new List();
+ ulong? requested = null;
+
+ var applied = GcHeapLimit.Apply(profile, logs.Add, bytes => requested = bytes, () => "8192");
+
+ Assert.True(applied);
+ Assert.Equal(8192UL * 1024 * 1024, requested); // env value, not the profile's 5120
+ Assert.Contains(logs, l => l.Contains("set to 8192 MB by CRAFT_GC_HEAP_LIMIT_MB", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void EnvOverrideZero_DisablesTheCap_OverridingTheProfile()
+ {
+ var profile = new SkuProfile { GCHeapHardLimitMB = 5120 };
+ var logs = new List();
+ ulong? requested = 123; // sentinel: proves the 0 is what actually reaches refresh
+
+ var applied = GcHeapLimit.Apply(profile, logs.Add, bytes => requested = bytes, () => "0");
+
+ Assert.True(applied);
+ Assert.Equal(0UL, requested);
+ Assert.Contains(logs, l => l.Contains("disabled by CRAFT_GC_HEAP_LIMIT_MB", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void EnvOverrideZero_DisablesTheCap_WithNoProfile()
+ {
+ var logs = new List();
+ ulong? requested = 123;
+
+ var applied = GcHeapLimit.Apply(null, logs.Add, bytes => requested = bytes, () => "0");
+
+ Assert.True(applied);
+ Assert.Equal(0UL, requested);
+ Assert.Contains(logs, l => l.Contains("disabled by CRAFT_GC_HEAP_LIMIT_MB", StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not-a-number")]
+ [InlineData("-5")] // negative = invalid, not a request to disable; that's what 0 is for
+ public void EnvOverrideAbsentOrInvalid_DefersToProfile(string? env)
+ {
+ var profile = new SkuProfile { GCHeapHardLimitMB = 5120 };
+ var logs = new List();
+ ulong? requested = null;
+
+ var applied = GcHeapLimit.Apply(profile, logs.Add, bytes => requested = bytes, () => env);
+
+ Assert.True(applied);
+ Assert.Equal(5120UL * 1024 * 1024, requested);
+ Assert.Contains(logs, l => l.Contains("set to 5120 MB by SkuProfile", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void EnvOverrideZero_WhenRefreshRefuses_LogsAndKeepsBaseline()
+ {
+ var logs = new List();
+
+ var applied = GcHeapLimit.Apply(null, logs.Add,
+ _ => throw new InvalidOperationException("nope"), () => "0");
+
+ Assert.False(applied);
+ Assert.Contains(logs, l => l.Contains(
+ "GC heap hard limit change (disable) by CRAFT_GC_HEAP_LIMIT_MB failed", StringComparison.Ordinal));
+ }
}
From 9979e409cb821f4d91cdbd87fdb1c6a0d024ab60 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Mon, 31 Aug 2026 19:58:34 +0800
Subject: [PATCH 09/17] fix(powershell): reset async local leaks
Add a shared PipelineExecutionContext helper that captures a clean execution-context baseline during worker initialization and restores it during per-invocation cleanup. This prevents AsyncLocal values from leaking between invocations on ReuseThread workers while preserving runspace session state, and adds focused tests covering worker behavior, thread-reuse leak reproduction, and reset strategy validation/benchmarking.
---
.../PipelineExecutionContext.cs | 54 ++++++
Services/PowerShellHost/PowerShellWorker.cs | 39 +++++
tests/Craft.Tests/ExecutionContextReset.cs | 67 +++++++
.../PowerShellWorkerContextResetTests.cs | 45 +++++
tests/Craft.Tests/ThreadReuseContextTests.cs | 163 ++++++++++++++++++
5 files changed, 368 insertions(+)
create mode 100644 Services/PowerShellHost/PipelineExecutionContext.cs
create mode 100644 tests/Craft.Tests/ExecutionContextReset.cs
create mode 100644 tests/Craft.Tests/PowerShellWorkerContextResetTests.cs
create mode 100644 tests/Craft.Tests/ThreadReuseContextTests.cs
diff --git a/Services/PowerShellHost/PipelineExecutionContext.cs b/Services/PowerShellHost/PipelineExecutionContext.cs
new file mode 100644
index 0000000..7234317
--- /dev/null
+++ b/Services/PowerShellHost/PipelineExecutionContext.cs
@@ -0,0 +1,54 @@
+namespace Craft.PowerShellHost;
+
+///
+/// Clears per-invocation AsyncLocal state that would otherwise leak across invocations on a
+/// worker's reused pipeline thread (PSThreadOptions.ReuseThread). An AsyncLocal value lives in
+/// the thread's ; runspace SessionState (module
+/// $script: variables, ModuleInjections caches) and process env do NOT — so restoring a clean
+/// baseline ExecutionContext drops the per-request AsyncLocal state while leaving persisted per-worker
+/// state untouched.
+///
+/// A captured ExecutionContext is an immutable snapshot, not thread-bound, so a single clean baseline
+/// captured once (at worker init, before any request context exists) can be restored onto every
+/// worker's pipeline thread. Uses only the public, supported
+/// /
+/// (public
+/// since net8). Both entry points must run ON the pipeline thread (i.e. via a pipeline invocation).
+///
+public static class PipelineExecutionContext
+{
+ private static System.Threading.ExecutionContext? s_baseline;
+ private static volatile bool s_captured;
+
+ /// True once a baseline has been captured for the pool (worker init ran at least once).
+ public static bool Captured => s_captured;
+
+ /// True if the captured baseline is the runtime default (Capture() returned null), in which
+ /// case is a no-op — there is no public way to Restore the default context.
+ public static bool BaselineIsDefault => s_captured && s_baseline is null;
+
+ ///
+ /// Capture the clean baseline once, from the calling (pipeline) thread. Idempotent — only the first
+ /// call captures. Call at worker init, after warmup, before serving invocations.
+ ///
+ public static void CaptureBaselineIfNeeded()
+ {
+ if (s_captured) return;
+ s_baseline = System.Threading.ExecutionContext.Capture(); // clean, post-warmup context (no per-request AsyncLocals)
+ s_captured = true; // volatile write publishes s_baseline
+ }
+
+ ///
+ /// Restore the clean baseline onto the calling (pipeline) thread, dropping any AsyncLocal set during
+ /// the invocation. Throws if no baseline was ever captured; the caller catches, logs and continues.
+ ///
+ public static void Reset()
+ {
+ if (!s_captured)
+ throw new InvalidOperationException("ExecutionContext baseline was never captured for this worker pool.");
+ var baseline = s_baseline;
+ if (baseline is not null)
+ System.Threading.ExecutionContext.Restore(baseline); // public API (net8+)
+ // else: warmup context was the runtime default; nothing to Restore via the public API.
+ }
+}
diff --git a/Services/PowerShellHost/PowerShellWorker.cs b/Services/PowerShellHost/PowerShellWorker.cs
index fe3a832..33a0be5 100644
--- a/Services/PowerShellHost/PowerShellWorker.cs
+++ b/Services/PowerShellHost/PowerShellWorker.cs
@@ -216,6 +216,20 @@ public void Initialize(ScriptRepository repo, string apiBasePath, CraftSettings
catch (Exception ex) { _logger.LogWarning("Post-init script failed: {Error}", ex.Message); }
}
+ // Capture the clean ExecutionContext baseline once (shared across the pool) so Cleanup can reset
+ // per-invocation AsyncLocal state on the reused pipeline thread. Runs on the pipeline thread via
+ // RunScript. Never fail worker init over this.
+ try
+ {
+ RunScript("[Craft.PowerShellHost.PipelineExecutionContext]::CaptureBaselineIfNeeded()");
+ if (PipelineExecutionContext.BaselineIsDefault)
+ _logger.LogWarning("Worker{Id}: ExecutionContext baseline captured as the runtime default; per-invocation reset will no-op.", Id);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Worker{Id}: failed to capture ExecutionContext baseline; per-invocation reset will be skipped.", Id);
+ }
+
_initialized = true;
_logger.LogInformation("Worker{Id}: {Count} functions deployed", Id, deployed);
}
@@ -418,10 +432,35 @@ private void Cleanup()
{
_pwsh.Commands.Clear();
_pwsh.Streams.ClearStreams();
+ ResetPipelineExecutionContext();
CleanupGlobalVariables();
CleanupJobs();
}
+ ///
+ /// Reset the reused pipeline thread's ExecutionContext to the clean baseline, dropping any AsyncLocal
+ /// set during the invocation (which would otherwise leak to the next invocation on this worker). Runs
+ /// as a minimal pipeline so it executes ON the pipeline
+ /// thread. SessionState (module $script: vars, injected caches) is not in the ExecutionContext, so it
+ /// is unaffected. Never fail an invocation over this — catch, log, continue.
+ ///
+ private void ResetPipelineExecutionContext()
+ {
+ try
+ {
+ _pwsh.AddScript("[Craft.PowerShellHost.PipelineExecutionContext]::Reset()").Invoke();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Worker{Id}: per-invocation ExecutionContext reset failed; continuing.", Id);
+ }
+ finally
+ {
+ _pwsh.Commands.Clear();
+ _pwsh.Streams.ClearStreams();
+ }
+ }
+
private void CleanupGlobalVariables()
{
if (s_builtinGlobalVars == null) return;
diff --git a/tests/Craft.Tests/ExecutionContextReset.cs b/tests/Craft.Tests/ExecutionContextReset.cs
new file mode 100644
index 0000000..ed13c98
--- /dev/null
+++ b/tests/Craft.Tests/ExecutionContextReset.cs
@@ -0,0 +1,67 @@
+using System.Reflection;
+
+namespace Craft.Tests;
+
+///
+/// "Solution 1": reset the reused pipeline thread's ExecutionContext to a clean warmup baseline
+/// between invocations, dropping any AsyncLocal value set during an invocation. Runspace SessionState
+/// (module $script: vars, ModuleInjections caches) and process env do NOT live in the ExecutionContext,
+/// so this is safe for persisted per-worker state.
+///
+/// Mechanism: + — both
+/// PUBLIC and supported on net8/net9/net10. Restore throws on a null argument, so we restore a baseline
+/// captured on the pipeline thread at warmup (when it is clean). In the rare case the warmup context is
+/// the runtime default (Capture() == null), we fall back to the internal RestoreInternal(null); a fix
+/// that had to do that would probe it at startup and degrade to Solution 2 if absent.
+///
+/// Must be driven ON the pipeline thread: once at warmup, then
+/// at each invocation boundary.
+///
+public static class ExecutionContextReset
+{
+ [ThreadStatic] private static ExecutionContext? s_baseline;
+ [ThreadStatic] private static bool s_haveBaseline;
+
+ // Only used when the captured warmup baseline is the default (null) context, which public Restore rejects.
+ private static readonly MethodInfo? s_restoreInternalNull =
+ typeof(ExecutionContext).GetMethod("RestoreInternal",
+ BindingFlags.Static | BindingFlags.NonPublic, binder: null,
+ types: new[] { typeof(ExecutionContext) }, modifiers: null);
+
+ /// Whether the supported public reset primitive exists on this runtime (the reliability answer).
+ public static bool PublicRestoreAvailable { get; } =
+ typeof(ExecutionContext).GetMethod("Restore",
+ BindingFlags.Static | BindingFlags.Public, binder: null,
+ types: new[] { typeof(ExecutionContext) }, modifiers: null) != null;
+
+ /// Capture the current (clean) context as the per-thread baseline. Call at warmup, on the pipeline thread.
+ public static void CaptureBaseline()
+ {
+ s_baseline = ExecutionContext.Capture(); // may be null if the warmup context is the runtime default
+ s_haveBaseline = true;
+ }
+
+ /// What ResetToClean would use on the calling thread — for reporting.
+ public static string Mechanism =>
+ !s_haveBaseline ? "(baseline not captured on this thread)"
+ : s_baseline != null ? "ExecutionContext.Restore(baseline) [public, supported]"
+ : s_restoreInternalNull != null ? "ExecutionContext.RestoreInternal(null) [internal; warmup ctx was default]"
+ : "unavailable (default baseline, no internal fallback)";
+
+ /// Restore the warmup baseline on the calling (pipeline) thread, dropping AsyncLocals set since.
+ public static void ResetToClean()
+ {
+ if (!s_haveBaseline) CaptureBaseline(); // best effort; prefer an explicit warmup capture
+ if (s_baseline != null)
+ {
+ ExecutionContext.Restore(s_baseline); // supported public API (net8+)
+ return;
+ }
+ if (s_restoreInternalNull != null)
+ {
+ s_restoreInternalNull.Invoke(null, new object?[] { null });
+ return;
+ }
+ throw new NotSupportedException("Warmup context was the default and no internal null-restore is available.");
+ }
+}
diff --git a/tests/Craft.Tests/PowerShellWorkerContextResetTests.cs b/tests/Craft.Tests/PowerShellWorkerContextResetTests.cs
new file mode 100644
index 0000000..17a705c
--- /dev/null
+++ b/tests/Craft.Tests/PowerShellWorkerContextResetTests.cs
@@ -0,0 +1,45 @@
+using System.Management.Automation;
+using System.Management.Automation.Runspaces;
+using Craft.PowerShellHost;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Craft.Tests;
+
+/// A process-static AsyncLocal, so the leak survives the worker's global-variable sweep and we
+/// are testing the ExecutionContext reset specifically (not variable cleanup). Its .Value lives in the
+/// pipeline thread's ExecutionContext.
+public static class LeakProbe
+{
+ public static readonly System.Threading.AsyncLocal