Keep the "psutil answers without a fork" and "ps stays the fallback when psutil is denied" cases; the dead-PID and agreement cases are change-detectors.
`_read_process_cmdline` tried `/proc`, then `ps`, then psutil. macOS and BSD have no `/proc`, so
every call there forked `ps -p <pid> -o command=` while psutil — a pinned core dependency, which
pyproject calls "the canonical answer" for PID questions — sat unused behind it, returning the same
string in-process.
That call is the gateway-identity check guarding against PID reuse, reached for every profile with
a live gateway, and `list_profiles()` runs it per profile. `list_profiles` is the shared body of
`GET /api/profiles` and `profiles.list`, which the Bots roster polls every 5s per connection, so
the forks repeat forever on an idle machine.
Measured on macOS: one call 4.17ms via `ps` against 0.022ms via psutil. End to end against a home
whose profiles each hold a live gateway lock, A/B in one process state, two rounds: 4 gateways
23.9ms -> 4.0ms, 8 gateways 49.0ms -> 9.3ms.
`ps` stays as the fallback rather than being replaced: on macOS psutil raises AccessDenied for a
process owned by another user, which `ps` still reports — real when a dashboard probes root-owned
LaunchDaemon gateways. That path is unchanged and simply pays a cheap failed lookup first. For a
readable process both sources return byte-identical strings, so the command-line matchers see no
difference, and Linux still answers from /proc without reaching either.
Regressions: psutil answers without any fork (a raising `subprocess.run` proves it); the `ps`
fallback still answers when psutil refuses, as it does across users; a dead PID reports nothing
from either; and both sources agree on a live gateway-shaped process. Restoring the old order
fails the first.
Fixes#117270