Send SIGXCPU and SIGKILL at the guest CPU limits

XA
17h ago
+608-14

Intent: a guest RLIMIT_CPU limits the guest's CPU time as it does on Linux. This is follow-up 2 of #448, which stores the limit in the guest table and leaves it unenforced.

Failure fixed: on main, a guest that sets RLIMIT_CPU to 1 s and spins gets no signal and runs until the vCPU watchdog ends elfuse after 10 s with status 124. On Linux the guest gets SIGXCPU after 1 s of CPU time.

This PR follows check_process_timers (kernel/time/posix-cpu-timers.c). At the soft limit the process gets SIGXCPU and the soft limit rises by one second, so a process that catches or ignores SIGXCPU gets another one each CPU second. At the hard limit it gets SIGKILL; when soft equals hard, SIGKILL alone. A guest spinning at EL0 takes no exit where elfuse could check the limit, so rlimit_cpu_main samples the host process CPU time, the clock guest CLOCK_PROCESS_CPUTIME_ID reads, from a thread of its own. The thread starts on the first finite soft limit (boot seed, fork-child restore, or prlimit64) and sleeps for the remaining CPU time divided by the online CPU count, at least 10 ms. A limit change wakes it.

Review focus:

  • rlimit_lock is a leaf, so the sampler drops it before signal_queue_info, which takes sig_lock.
  • The soft limit is raised before SIGXCPU is queued, so a handler that calls getrlimit reads the new value, as on Linux.
  • Both signals are queued as SI_KERNEL with si_pid 0, matching SEND_SIG_PRIV in check_rlimit.
  • A fork child starts the sampler through sys_rlimit_cpu_start, called right after signal_set_state, since that call replaces the shared pending set and would drop a signal queued earlier.
  • elfuse ignores host SIGXCPU and lifts its own RLIMIT_CPU soft limit to the hard one, as eeada5b does for RLIMIT_FSIZE. XNU sends nothing at the hard limit, so the sampler sends SIGKILL at the lower of the guest and host hard limits, which keeps a guest with euid 0 from raising its way past the host limit.

Known gaps: a fork child is a new host process, so its CPU time starts with what its elfuse process spent before the guest resumes (a fresh launch measured 6 to 17 ms), where a Linux child starts at 0. The signal can land up to 10 ms after the limit, the sampler's shortest sleep. A fatal default signal taken by a worker thread ends only that thread (#476), so the sampler sends SIGKILL again every 10 ms, and a default SIGXCPU taken by a worker is not sent again. Under a finite host RLIMIT_CPU hard limit, elfuse stalls before the guest runs, on main as well (#474), so the host hard limit is checked only with a build that reads it from the environment. A CLONE_VM child without CLONE_THREAD runs in its parent's host process, so the two share one CPU clock and the parent can be signalled for the child's CPU time (#478).

Verified on 8134b88, rebased onto 3347188: make check-format and make check pass; make test-matrix-elfuse-aarch64 gives 317 passed, 0 failed, 10 skipped.