Skip to content

Export DOTNET_GCHeapHardLimit from MEMORY_LIMIT to fix cgroup v2 memory handling - #1317

Closed
rkoster wants to merge 4 commits into
cloudfoundry:masterfrom
rkoster:fix-dotnet-gc-heap-hard-limit-1100
Closed

Export DOTNET_GCHeapHardLimit from MEMORY_LIMIT to fix cgroup v2 memory handling#1317
rkoster wants to merge 4 commits into
cloudfoundry:masterfrom
rkoster:fix-dotnet-gc-heap-hard-limit-1100

Conversation

@rkoster

@rkoster rkoster commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #1100 ("Dotnet Core Buildpack does not handle cgroup v2 memory limits correctly – app does not release memory properly").

On cgroup v2 (and "hybrid" v1+v2) Diego cells, the .NET GC can fail to detect the real container memory limit and treat memory as effectively unlimited. This is a documented, unresolved ambiguity in coreclr's own cgroup detection (see dotnet/runtime#34334: "It is possible to have both cgroup v1 and v2 to both be enabled on a host... this change will pick one, which may not be the correct one"). Note the cgroup hierarchy in play is a property of the BOSH stemcell running the Diego cell (the host kernel/OS), not the CF stackcflinuxfs4/cflinuxfs5 are just rootfs images bind-mounted into the app container and are orthogonal to which cgroup hierarchy the host kernel exposes; any stack can run on a Diego cell backed by any stemcell.

Rather than reimplementing cgroup-file parsing in the buildpack (which would just move the same ambiguity elsewhere), this PR has the buildpack read Cloud Foundry's own $MEMORY_LIMIT environment variable — which Cloud Controller computes and injects independently of any cgroup layout (see cloud_controller_ng/lib/cloud_controller/diego/environment.rb) — and uses it to explicitly set DOTNET_GCHeapHardLimit at container startup, bypassing the runtime's own detection entirely.

  • Adds a bash calculation block to the existing profile.d/startup.sh script (written by Finalizer.WriteProfileD), which runs at every container start (not baked in at staging time, since cf scale -m changes $MEMORY_LIMIT without restaging).
  • Defaults to 75% of the container memory limit (matching .NET's own documented default GCHeapHardLimitPercent), leaving headroom for non-GC-heap memory.
  • Fully respects user overrides: setting DOTNET_GCHeapHardLimit or DOTNET_GCHeapHardLimitPercent yourself (e.g. via cf set-env) is honored, with a safety clamp (+ logged warning) if an explicit override would exceed the real container limit.
  • No new custom environment variables introduced — reuses the native DOTNET_GCHeapHardLimit/DOTNET_GCHeapHardLimitPercent variables.
  • No cgroup file parsing, no new compiled binary — pure bash inside the existing profile.d mechanism.
  • Backward compatible: when $MEMORY_LIMIT is absent (e.g. non-CF environments), behavior is unchanged (no-op).
  • The integration fixture verifies the fix via GC.GetConfigurationVariables()["GCHeapHardLimit"] (available since .NET 7) rather than reading back the raw environment variable, proving the CLR itself parsed and applied the value into its live GC configuration.

Test Plan

  • Unit tests in src/dotnetcore/finalize/finalize_test.go cover the script's generated content and, via exec.Command("bash", ...), its actual runtime arithmetic across all documented scenarios (default 75%, custom percent, invalid/out-of-range percent fallback, user override respected, user override clamped with warning, MEMORY_LIMIT absent/malformed no-op, leading-zero octal edge cases, MEMORY_LIMIT=0m floor). go test -mod=vendor ./src/dotnetcore/finalize/... -run TestFinalize -v — 18/18 passing.
  • go vet -mod=vendor ./src/dotnetcore/... clean.
  • Added integration test coverage (src/dotnetcore/integration/memory_limit_test.go) via a debug endpoint on the shared simple fixture app that reports GC.GetConfigurationVariables()["GCHeapHardLimit"].
  • Ran the MemoryLimit integration suite end-to-end against the switchblade docker backend on a real deployed app (both the default-75% and DOTNET_GCHeapHardLimitPercent=50 override cases pass, confirmed against both .NET 8 and .NET 10 SDK images).
  • Full integration suite (all other suites: Default, Node, Fsharp, etc.) run against a real CF/Docker target — not run in this environment (would require building/testing unrelated language runtimes outside the scope of this change).

Fixes cloudfoundry#1100. Computes the .NET GC's
heap hard limit from CF's MEMORY_LIMIT env var (always authoritative,
cgroup-version-agnostic) at container startup, rather than relying on
the .NET runtime's own cgroup v1/v2/hybrid detection, which is
documented (dotnet/runtime#34334) to sometimes pick the wrong cgroup
hierarchy on hybrid hosts and treat the container as unlimited.

Forces base-10 interpretation (10#...) of MEMORY_LIMIT and
DOTNET_GCHeapHardLimitPercent digit strings so that leading-zero
values (e.g. 08, 09, 050) are never misinterpreted as octal or
rejected by bash's arithmetic context, bounds the percent string to
1-3 digits to reject overflow-prone inputs before they reach
arithmetic, and adds a floor so MEMORY_LIMIT=0m is a no-op instead of
exporting a zero-byte heap limit.
Adds a /env/dotnet-gc-heap-hard-limit debug endpoint to the shared
'simple' fixture app and a new MemoryLimit integration suite that
deploys it and asserts the exported DOTNET_GCHeapHardLimit value,
both with the default 75% and with a caller-provided
DOTNET_GCHeapHardLimitPercent override.
Clarifies (per final code review) that a plain decimal-looking
DOTNET_GCHeapHardLimit override, e.g. "268435456", is parsed as hex
rather than decimal - matching .NET's own native parsing convention
for this env var.
Replaces reading back the raw DOTNET_GCHeapHardLimit environment
variable with querying GC.GetConfigurationVariables()["GCHeapHardLimit"]
(available since .NET 7). This proves the CLR itself parsed and applied
the value into its live GC configuration, rather than merely confirming
the shell exported the variable into the process environment.

Verified against the real dotnet SDK (8.0 and 10.0 images) that the
"GCHeapHardLimit" key is always present as a System.Int64 (0 when
unconfigured), and re-ran the MemoryLimit integration suite end-to-end
against the docker switchblade backend - both cases still pass with
the same expected hex values (0x30000000 default, 0x20000000 with a
DOTNET_GCHeapHardLimitPercent=50 override).
@rkoster

rkoster commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Update: this workaround is unnecessary once #1318 lands — closing in favor of #1318

While investigating the related .NET 10 segfault issue, I found that the root cause behind this PR ("GC can fail to detect the real container memory limit and treat memory as effectively unlimited") is the same underlying upstream bug fixed in #1318/#1319: dotnet/runtime#130092GetCGroupMemoryLimitV2 looping/underflowing when the container's cgroup path is itself the root of the cgroup v2 mount (exactly Cloud Foundry Garden's layout). That bug was fixed upstream (dotnet/runtime#130377), backported to release/10.0 (dotnet/runtime#130404, milestone 10.0.11), and shipped in .NET 10.0.11 (2026-08-11).

Verification

I built a test buildpack with the dependency bump from #1318/#1319 (dotnet-runtime/dotnet-aspnetcore 10.0.11, dotnet-sdk 10.0.400) — without this PR's DOTNET_GCHeapHardLimit bash workaround — and deployed the simple fixture (with the /env/dotnet-gc-heap-hard-limit debug endpoint from this PR, reporting GC.GetConfigurationVariables()["GCHeapHardLimit"]) to a real CF foundation with -m 256M.

Result:

Metric Value
cgroup memory.max (container, app + Envoy sidecar) 301989888 bytes = 288 MiB
GC.GetConfigurationVariables()["GCHeapHardLimit"] 0xd800000 = 226492416 bytes = 216 MiB
Ratio exactly 75% — .NET's documented default GCHeapHardLimitPercent

This shows the CLR correctly read the real container cgroup v2 memory limit on its own and applied its standard 75% default — with no DOTNET_GCHeapHardLimit, no MEMORY_LIMIT-derived bash calculation, no configuration at all.

Conclusion

Once #1318/#1319's dependency bump to .NET 10.0.11 lands, the CLR's built-in cgroup v2 detection works correctly and this PR's profile.d workaround becomes redundant complexity for the versions that matter going forward. Closing this in favor of #1318, which fixes the actual root cause via the upstream dependency bump rather than working around it.

(This approach could still have narrow value as a stopgap for teams pinned to older, unpatched .NET 8/9/10 runtime patch versions that predate the fix — but the clean, permanent fix is the version bump in #1318.)

Thanks @benjaminguttmann-avtq for the pointers (on slack).

@rkoster

rkoster commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of #1318, which fixes the actual root cause via the upstream .NET 10.0.11 dependency bump. See comment above for verification details.

@rkoster rkoster closed this Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dotnet Core Buildpack does not handle cgroup v2 memory limits correctly – app does not release memory properly

1 participant