Skip to content

feat(memfill): join target cgroup without cgexec - #478

Open
achoimet wants to merge 1 commit into
mainfrom
feat/memfill-join-cgroup-without-cgexec
Open

feat(memfill): join target cgroup without cgexec#478
achoimet wants to merge 1 commit into
mainfrom
feat/memfill-join-cgroup-without-cgexec

Conversation

@achoimet

Copy link
Copy Markdown
Member

Why

memfill placed its process into the target's memory cgroup with cgexec -g memory:<path>, which depends on libcgroup-tools (cgroup-tools on deb, libcgroup-tools on rpm). That package:

  • does not exist for Enterprise Linux 9 — Red Hat removed libcgroup in RHEL 9 (solution 7115132) and it was never packaged for EPEL 9. So steadybit-extension-host and steadybit-extension-container cannot be installed on RHEL 9 / Rocky 9 / Alma 9 (nothing provides /usr/bin/cgexec).
  • predates cgroup v2 (the shipped libcgroup is 0.41), so even where it installs, cgexec -g memory: does not do the right thing on the unified hierarchy that RHEL 9 / Ubuntu 22+ use by default.

This is the long-term fix for the packaging problem worked around in extension-host#240 and setup-scripts#71.

What

Join the cgroup directly instead of shelling out to cgexec. A tiny sh wrapper writes its own PID to <cgroup>/cgroup.procs and then execs memfill:

  • cgroup v1 (memory controller) is preferred, v2 unified is the fallback — matching the historical memory:<path> semantics while adding v2 support.
  • The move happens before exec: under cgroup v2 a process keeps memory already charged to it when migrated, so joining first (while nothing is allocated yet) and then execing keeps the same PID and charges all of memfill's allocation to the target cgroup.
  • The cgroup path is passed as a positional argument, not interpolated into the script, so it cannot break out of the shell.

The public API (NewMemfillProcess) is unchanged, so extension-host and extension-container pick this up on their next action-kit bump with no code changes.

Verification

  • Unit test asserts the exact command vector and that cgexec no longer appears.
  • Validated the join wrapper in a cgroup v2 container: the exec'd process reports 0::/memfilltest and appears in the target's cgroup.procs — i.e. memfill runs inside the target cgroup, move-before-exec confirmed.

Follow-up (separate PRs, after this is released)

Once a released action-kit carries this, both extensions can drop the cgexec dependency from their .goreleaser.yaml entirely (rpm Requires/Recommends and deb Depends), which also restores the fill-memory attack on EL9 / cgroup-v2 hosts.

memfill placed its process into the target's memory cgroup via
'cgexec -g memory:<path>', which depends on libcgroup-tools
(cgroup-tools / libcgroup-tools). That package does not exist for
Enterprise Linux 9 and never supported cgroup v2, so the extensions
using memfill (extension-host, extension-container) could not be
installed on RHEL 9 / Rocky 9 / Alma 9, and fill memory could not
charge the right cgroup on cgroup-v2 hosts.

Join the cgroup directly instead: a tiny sh wrapper writes its own PID
to <cgroup>/cgroup.procs (cgroup v1 memory controller preferred, v2
unified fallback) and then execs memfill. The move happens before exec
so nothing is allocated in the wrong cgroup under v2.

This removes the cgexec runtime dependency entirely, letting the rpm
packages drop it from both Requires and Recommends.
@sonarqubecloud

Copy link
Copy Markdown

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @achoimet's task in 2m 52s —— View job


Claude's Review

  • Check previously unresolved review threads (none found)
  • Gather context (diff, files, related code)
  • Review code quality, bugs, security, concurrency
  • Post findings

Note: I wasn't able to run go build/go test in this sandbox (shell commands required approval that wasn't available), so this review is based on static reading of the code and cross-referencing the unit test's expected argument vector.

Summary

This is a well-scoped, well-verified change. The sh wrapper logic in go/action_kit_commons/memfill/memfill_process.go correctly:

  • Takes the cgroup path as a positional arg ($1) rather than interpolating it into the script text, so it can't be used for shell injection.
  • Prefers cgroup v1 (/sys/fs/cgroup/memory<path>) and falls back to v2 unified (/sys/fs/cgroup<path>), matching the documented intent.
  • Writes $$ to cgroup.procs before exec "$@", preserving the same PID across the exec chain (shnsentermemfill), which is required for cgroup v2's "keep already-charged memory" semantics described in the PR body.
  • Traced through the full nsenter/sh argument chain by hand (cg="$1"; shift then exec "$@") — it matches exactly what TestMemfillCommandArgs asserts, and no cgexec remains in actual code paths (only in a doc comment describing the old behavior and in the test's negative assertion).

No NewMemfillProcess/memfillCommandArgs callers exist elsewhere in this repo, consistent with the PR's claim that the public API is unchanged.

Minor, non-blocking observations

  • There's a small TOCTOU window between the [ -e ".../cgroup.procs" ] check and the printf > "$procs" write (e.g., if the target cgroup is removed in between). This is handled gracefully — the write failure is caught and reported via the existing || fallback — so it's not a correctness issue, just worth knowing about.
  • Cgroup-path resolution itself (ociruntime.readCgroupPath/parseProcCgroupFile) is unchanged by this PR, so the existing behavior of picking the lowest-hid v1 hierarchy (not necessarily the memory controller specifically) carries over as-is — not introduced by this change.

No bugs, security issues, or concurrency problems found in the diff.

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.

1 participant