Skip to content

Implement ionice - #637

Open
mmclinton wants to merge 3 commits into
uutils:mainfrom
mmclinton:ionice-implement
Open

Implement ionice#637
mmclinton wants to merge 3 commits into
uutils:mainfrom
mmclinton:ionice-implement

Conversation

@mmclinton

Copy link
Copy Markdown
Contributor

ionice shows or changes the I/O scheduling class and priority of a process. This is all of ionice(1): -c, -n, -p, -P, -u, -t, -h, -V, reading a priority with no arguments, setting one on a pid, pgid or uid, and running a command under a priority with the 126 and 127 statuses that go with a failed exec. No option is half implemented. It is Linux only; the other targets compile and exit 1 with a message.

I built this against util-linux 2.42.2 and checked it against 2.37.4, 2.39.2 and 2.41.1 as well, comparing stdout, stderr, exit status and the arguments of the ioprio_get and ioprio_set syscalls over 139 invocations. Those four references do not differ from each other on a single one of those cases, so what follows holds for all of them.

The documented ranges are enforced

-c outside 0-3 and -n outside 0-7 are rejected with exit status 1 and no syscall. util-linux accepts most of them and exits 0 - it refuses only the ones whose wrap lands on a class the kernel will not take, such as -c 4 and -n -1 - so this is a deliberate divergence in which arguments succeed, not just in what gets printed. It is the question I raised in #624, and the answer there was to enforce and document.

The reason it is worth a divergence is that the accepted values do not mean what they read as. The kernel takes the class from three bits and reads the level modulo eight, so ionice -c 99 runs idle, ionice -n 8192 changes the class outright, and -n 8 is accepted as a level of 0 - the highest best-effort priority, one step past the bottom of the documented range rather than one step below it. -c 10 prints unknown prio class 10 and then runs best-effort at exit 0 anyway; with -t it prints nothing at all.

Three things follow that whoever reviews this may otherwise notice:

  • The ionice: unknown prio class N warning does not exist here, because no class can be unknown any more. -t therefore has one fewer thing to silence.
  • Exit status 1 is a choice for the values util-linux accepts. It exits 0 for those, so there was no status to copy, and 1 is what it already uses for the argument errors it does diagnose. For the values it refuses outright, -c 4 and -n -1 among them, we agree with it at 1.
  • A value too wide for an i32 still reports ERANGE, unchanged, because the reference errors there too.

ionice.md and --help are deliberately not amended. They already state both ranges, and per Sylvestre's advice this body is the disclosure.

Two behaviors that are not in ionice(1)

Neither is visible in the output, and both are matched here:

  • The idle class always carries priority level 7 and none always carries 0, whatever -n asks for.
  • A lone id of 0 on the read path means the calling process, not the process group or the user numbered 0.

The syscalls

There are no libc wrappers for ioprio_get and ioprio_set, so ioprio.rs calls them through syscall(). Those two blocks are the only unsafe in the crate.

What the tests do not cover

55 integration tests, plus one for the non-Linux arm. Four items worth naming:

  • No root path. A successful realtime set needs CAP_SYS_NICE, so only the EPERM refusal is tested. A user namespace does not help: the kernel checks realtime ioprio against the initial user namespace, so uid 0 inside unshare -Ur is refused exactly as before.
  • Write-and-read-back only covers -p. -P and -u writes are never verified against a live target.
  • Nothing exercises -c or -n on a process the caller does not own beyond the EPERM case, which earns its refusal from pid 1 belonging to root rather than from the class being lowered.
  • Untested: architectures other than x86_64, signal delivery during exec, and very large id lists.

Closes #624

mmc added 3 commits August 29, 2026 12:27
ionice reads and sets the I/O scheduling class and priority of a process
through ioprio_get and ioprio_set. The option surface follows ionice(1):
-c, -n, -p, -P, -u and -t, with trailing operands being further ids when an
id option was given and the command to run otherwise. Behavior throughout is
what util-linux 2.42.2 was observed doing; no C source was read.

The parts a reader should not have to reverse engineer:

- The idle class carries priority level 7 and none carries 0, whatever -n
  asks for. Every idle priority prints as the bare word "idle" regardless of
  the level packed with it, so stdout cannot show this - only the syscall
  arguments can. None is forced by the kernel, which refuses a none class
  carrying a nonzero level; idle's 7 is the reference's convention.
- A lone id of 0 on the read path means the calling process rather than the
  group or user numbered 0, and only when there are no further ids and no
  priority is being set. Writing never falls back: -c 3 -u 0 really does
  target uid 0.
- Options are diagnosed left to right, reconstructed from clap's value
  indices, so the leftmost bad one is reported. A second id option is
  rejected before its value is looked at.
- -c outside 0-3 and -n outside 0-7 are rejected with exit 1. ionice(1)
  documents both ranges and util-linux 2.42.2 enforces neither: it hands the
  value to the kernel, which reinterprets it wherever the reinterpretation is
  legal, so -c 99 lands on idle and -n 8192 changes the class outright, both
  exiting 0, and an accepted -n 8 really means level 0 because the kernel
  reads the level modulo eight. Where the wrap lands on something the kernel
  will not take, as with -c 4 and -n -1, the set fails EINVAL and the
  reference exits 1 - which is what we exit as well. This is a
  deliberate divergence in which argv succeed, put to the maintainers as
  uutils#624 and answered with "you can enforce and document it".
  The disclosure belongs in the pull request body, so ionice.md and --help
  are left alone on purpose. One rider comes with it: the "unknown prio class
  N" warning no longer exists, because no class can be unknown any more, so
  -t has one fewer diagnostic to silence.
- A value too wide for an i32 keeps its ERANGE wording, which is a failure
  the reference produces as well, and it is reported ahead of the documented
  range.
- ioprio.rs owns the ioprio word - its bit layout, the class numbers and
  names, how it renders, and the two syscalls that carry it, which are the
  crate's only unsafe. ionice.rs owns what this tool decides to put in that
  word. The class stays an i32 rather than becoming an enum because the
  kernel treats it as an open value.

Known divergences, each structural to clap rather than a defect in the port:
the --help and --version layout, clap's diagnostics where getopt has its own,
and clap stripping the = from an attached short-option value, which is
tree-wide and which merged utilities already carry.
Fifty-five tests, plus one for the non-Linux arm: reading a priority by
pid, pgid and uid, the lone-zero-id
fallback, setting each class and level, the two classes that discard a given
level, argument rejection on every numeric option, the command path with its
126 and 127 statuses, and the test_invalid_arg smoke test every other util
carries.

They defend the decisions a refactor would plausibly undo rather than
restating what clap already guarantees. The idle level is read back with a
direct syscall rather than through the tool's own encoder, because stdout
prints the bare word "idle" whatever level is packed with it and an
assertion sharing the encoder would agree with it wherever it was wrong. The
lone-zero-id fallback, the left-to-right order of diagnosis, and the
precedence of an i32 overflow over the documented range each have a test
whose only job is to fail if someone tidies them away.

Anything that needs a set to be refused is guarded on geteuid and skipped
under root, which would let the set succeed - and in the pid 1 case would
re-class init rather than earning the EPERM the test expects. CI is never
root, so these run everywhere it matters.

Diagnostics are asserted only as far as we write them; the errno text after
our half belongs to libc.

Two of them are about argument bytes rather than about ionice. The coreutils
contributing guide asks that anything that could be an invalid-Unicode path
stay OsStr, and here that is the command to exec: it is taken as an OsString
and handed to process::Command untouched. Nothing proved it. A directory makes
the discriminator cheap - exec refuses one with EACCES, so a 126 says the name
reached the kernel intact where a mangled one would have named nothing and
given 127. The other test holds the value options to the same rule from the
other side: a -p value that is not valid Unicode has to reach our parser and be
diagnosed as a bad PID, not be refused by clap first.

Both were checked against a deliberately broken build before being kept: a
String value parser on -p fails the second, a lossy Command::new fails the
first, and neither test fails for the other's reason.
Both said the idle class has no priority level. It has one; ionice just does
not print it. The kernel takes whatever level is packed with an idle set and
hands it back unchanged - fixed_data packs 7 with every idle set, and the
integration tests assert that ioprio_get returns (3 << 13) | 7 for a process
one was applied to, which is only possible because the level survives.

The wording mattered for the next person to touch either place: told the
kernel ignores the level, they would read the 7 as arbitrary and the
read-back assertion as pointless, and weakening that assertion would remove
the only check on our idle encoding, since stdout prints the bare word "idle"
whatever level it carries.

The claim behind the choice of 7 is unchanged and still stands: nothing in the
kernel forces it, so it is the reference's convention that we follow.

Comment text only; no code line moved.
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.

ionice: should the port enforce the documented ranges for -c and -n?

1 participant