proc: real CPU time, a tty number, and an exe that is a symlink

Four Gate 0 rows, and a fifth found while checking one of them.

**getrusage() did not distinguish RUSAGE_CHILDREN from RUSAGE_SELF**, which
undersells it: it ignored `who` entirely and reported the machine's *uptime*
as the caller's user time. Every process of every age answered with the same
number. Per-process CPU time is sampled at the timer tick now and split on the
interrupted privilege level; children's time is rolled up in wait() rather than
exit(), because RUSAGE_CHILDREN means *reaped* children and a running one has
not contributed yet.

**And wait4()'s fourth argument is a `struct rusage *` that was read off the
stack and thrown away.** That is the one shells actually use -- wait3() is the
same syscall with pid -1 -- so zsh's `time` reported

    16777216.01s user 1073.78s system 927983249% cpu

for every command. 2^24 seconds is uninitialised memory, and it did not vary
with the workload because it was never written. Now:

    busy loop   2.66s user  97% cpu   2.720 total
    sleep 2     0.02s user   0% cpu   2.248 total
    ls -lR /    0.70s user  65% cpu   1.070 total

**ru_stime is a structural zero and says so.** MSR_SFMASK includes FL_IF, so
SYSCALL clears interrupts and no tick can land while a process is inside a
syscall. Tick sampling cannot see kernel time at all here. Charging it needs
TSC deltas closed and reopened around every sched(), which is scheduler
accounting rather than sampling; the limitation is written down in
docs/architecture/kernel.md rather than papered over.

**/proc/<pid>/exe is a symlink.** It also needed a real st_size, and every
other procfs file deliberately reports zero -- readlink(2) bounds its copy by
it, so a symlink of size 0 resolves to the empty string, which looks like it
worked and is worse than the regular file was. Generating that one path in
ilock() costs one small render per stat() of one file.

**OVMF memory reporting is closed**: 1 GB reports 1017 MB usable, 2 GB reports
2041 MB. The firmware keeps about 7 MB either way, which is the right shape.

**And `ps` printed a header and nothing else**, which is not in any list
because nobody had run it. `ps` with no arguments shows only processes on
*this* terminal; /proc/<pid>/stat hardcoded tty_nr to 0, so nothing could ever
match. `ps -e` always worked, which is exactly what hid it. p->ctty was already
tracked -- procfs simply was not asked. Fields 14..17 were hardcoded zero too,
so TIME read 00:00:00 however long a process had run; they carry the new
counters now, and a shell that has burned 653 ticks reports 00:00:06.

Verified: utime climbs 18 -> 653 across a loop in the shell itself, `ps` lists
its own terminal, `ps -e` still lists everything, `readlink /proc/1/exe` gives
/bin/schinit, and sigtest/$()/rename/dmesg are all still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-09-22 02:45:00 +02:00
commit a5ff83de46
8 changed files with 199 additions and 26 deletions

View file

@ -175,15 +175,29 @@ gen_pid_stat(struct sb *s, const struct procinfo *pi)
sbputi(s, pi->pgid); // 5 pgrp
sbputc(s, ' ');
sbputi(s, pi->sid); // 6 session
sbputs(s, " 0 -1 0"); // 7 tty_nr, 8 tpgid, 9 flags
// 7 tty_nr. Linux's dev_t: major 12 bits above minor's 8. /dev/ttyN is
// major 1, minor 10+N in devfs, so a process whose ctty is 1 reports
// (1 << 8) | 11. This was hardcoded 0, and that is why plain `ps` printed a
// header and nothing else: ps with no arguments shows only the processes on
// *this* terminal, and nothing could ever match. `ps -e` always worked.
sbputc(s, ' ');
sbputi(s, pi->ctty ? ((1 << 8) | (10 + pi->ctty)) : 0);
sbputs(s, " -1 0"); // 8 tpgid, 9 flags
// 10..17: minflt cminflt majflt cmajflt utime stime cutime cstime.
// No per-process accounting yet, so these are honestly zero rather than
// invented -- a fabricated CPU time is worse than none, because top would
// draw a plausible graph of a number nobody measured.
for (i = 0; i < 8; i++)
// 10..13: minflt cminflt majflt cmajflt. There is no fault accounting, and
// a fabricated number is worse than none -- top would draw a plausible graph
// of something nobody measured.
for (i = 0; i < 4; i++)
sbputs(s, " 0");
// 14..17: utime stime cutime cstime, in ticks, which is the unit the field
// is defined in. These were zero too, which is why `ps` and `top` showed
// 00:00:00 for everything no matter how long it had been running.
sbputc(s, ' '); sbputi(s, (int64)pi->utime);
sbputc(s, ' '); sbputi(s, (int64)pi->stime);
sbputc(s, ' '); sbputi(s, (int64)pi->cutime);
sbputc(s, ' '); sbputi(s, (int64)pi->cstime);
sbputs(s, " 20 0 1 0 0 "); // priority nice num_threads itrealvalue starttime
sbputi(s, (int64)pi->sz); // 23 vsize, in bytes
sbputc(s, ' ');
@ -278,6 +292,27 @@ procfs_iload(struct inode *ip)
return;
}
// /proc/<pid>/exe is a symlink, as it is on Linux.
//
// It was a regular file holding the path as text, which reads the same from
// `cat` and is wrong to everything that matters: readlink() refused it with
// EINVAL, and `ps -o comm` -- which readlinks it -- fell back to the process
// name in brackets and printed `[init]` for everything.
//
// This one file has to report a real size, and every other procfs file
// deliberately reports zero. readlink(2) bounds its copy by ip->size, so a
// symlink whose size is 0 resolves to the empty string -- which is worse
// than the regular file was, because it looks like it worked. Generating it
// here costs one small render per stat() of one file.
if (!INO_IS_GLOBAL(ino) && INO_FILE(ino) == PF_EXE) {
char path[64];
int len = gen_file(ino, path, sizeof(path));
ip->type = T_SYMLINK;
ip->size = len > 0 ? (uint64)len : 0;
ip->nlink = 1;
return;
}
ip->type = T_FILE;
// Size is reported as zero.
//