Skip to content

limits: enabling pids/cpu in a non-root cgroup turns it into a thread root, and later spawns fail with EOPNOTSUPP #39

Description

@easyinplay

Version: 3.3.1 (the same code is on main as of v3.3.4)
Kernel: 7.0.0-15-generic, Ubuntu 26.04.1, systemd, cgroup v2 only; the process runs in user.slice/user-0.slice/session-N.scope

Cgroup::enable_controllers writes to the cgroup.subtree_control of the process's own cgroup, on the assumption that this fails with EBUSY whenever that cgroup is not the hierarchy root. That holds for memory. It does not hold for the threaded controllers: the kernel accepts +pids and +cpu on a populated non-root cgroup and switches it to domain threaded. From then on, writing a pid into cgroup.procs of any domain cgroup below it fails with EOPNOTSUPP, so every later spawn in that cgroup fails in the child's write_self_pid with Operation not supported (os error 95).

Steps, all in one session scope:

  1. Build a group with only max_processes set: ProcessGroup::with_options(ProcessGroupOptions::default().max_processes(64)).
  2. The scope's cgroup.type is now domain threaded, its cgroup.subtree_control is pids.
  3. Build any other group and start a command in it: Operation not supported (os error 95).
  4. echo -pids > cgroup.subtree_control on the scope: cgroup.type is domain again and spawns succeed.

Measured in that scope (5 member processes, cgroup.controllers = cpu memory pids), one write at a time, reverted after each:

write "+memory +pids +cpu" -> rejected, subtree_control unchanged, type=domain
write "+memory"            -> rejected, subtree_control unchanged, type=domain
write "+cpu"               -> accepted, subtree_control=[cpu],  type=domain threaded
write "+pids"              -> accepted, subtree_control=[pids], type=domain threaded

With type=domain threaded in place, cgroup_attach_permissions returns -EOPNOTSUPP from __cgroup_procs_write (ftrace).

How a caller ends up there: with_options with max_memory + max_processes fails as documented (the combined write is rejected). A caller that retries without the memory axis then asks for pids only, the write goes through, and that group's own spawn already fails. Because enablement is deliberately not reverted on Drop, the cgroup stays that way for every other process in it until someone writes -pids.

Expected: the pids-only request fails with LimitReason::Unenforceable like the memory one, and the parent cgroup is left untouched.

A possible fix: before writing, refuse with LimitReason::Unenforceable when the parent is not the hierarchy root (for example, when it has a cgroup.type file), instead of relying on the kernel to reject the write.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions