___ ___ __ _
/ _ \/ __|/ _| __ _ ___| |_ ___
| | | \__ \ |_ / _` |/ __| __/ __|
| |_| |__/ / _| (_| | (__| |_\__ \
\___/|___/|_| \__,_|\___|\__|___/
Scoped. Honest. Fast enough to poll.
Process + socket facts from the OS, nothing more.
One question: what does the OS say about these processes and their sockets?
Every tool we measured answered a different question. We wrote this one.
osfacts snapshot --roots 4242 --procs --ports # subtree: procs + listeners
osfacts snapshot --pids 991 --mem --start-time --cpu-time
osfacts snapshot --uid --cwd --status --argv # host-wide identity facets
osfacts socket-holders /run/user/1000/kaval.sock --procs # who holds this socket
osfacts host --load --mem --cpu --net --disk # machine gauges + counters
osfacts snapshot --procs --json | jq # same facts, readableComposable facets. You pay for the ask, not the host. Three verbs, because
three questions: a pid set (snapshot), a socket path
(socket-holders), the machine (host).
| shape (Linux ~450 procs) | cost |
|---|---|
one-process --procs --ports |
6.5 ms |
| host-wide, every process facet | 24.3 ms |
We measured seven candidates. Contract failures, not packaging:
| tool | disqualifier |
|---|---|
osquery |
fleet agent + SQL · ~378 ms/query · ~158 MB |
procs |
drops bind address (loopback ≡ wildcard) |
portls |
no PPID · no process table · no root walk |
rustnet |
interactive TUI · needs packet-capture privs |
portview |
listeners only · "scoped" = full scan + filter |
sysinfo + listeners |
double process enum · slower than us |
lsof / netstat |
93 ms (lsof) · macOS netstat lies about empty |
The whole trick.
cost(ask) ∝ size(subtree)
cost(host) ∝ size(host) ← everyone else does this always
- Other tools: full scan, then grep. "Scoped" 25.1 ms vs host-wide 26.0 ms = not scoping.
- Snapshot carries full pid→ppid. Grandchild listener walks home.
- Typical stack:
shell → npm → node. Listener-only tools show younodeand shrug about whose port it is.
Numbers from 31 interleaved warm runs on a Lenovo Linux box (AMD Ryzen 7 PRO 8840HS, 8c/16t, 64 GB RAM; 450–466 live procs). Stdout captured as a real client would.
| shape | before Linux pass | now |
|---|---|---|
host-wide --procs |
10.93 ms | 9.43 ms |
host-wide --procs --ports |
27.59 ms | 17.80 ms |
| host-wide, every process facet | 52.56 ms | 24.33 ms |
host --load --mem --cpu --net --disk |
2.61 ms | 2.61 ms |
| drishti two calls, serial | 55.41 ms | 26.48 ms |
one-process --roots + --procs --ports |
7.58 ms | 6.49 ms |
83-proc subtree --procs --ports |
19.85 ms | 17.27 ms |
Wins were boring: open shared proc files once, page-sized reads, reuse stat RSS, buffer stdout, parallel only large fd walks. Small scopes stay simple.
Two drivers → two terms:
| term | budget | measured (idle ~407 procs / 2725 fds) |
|---|---|---|
| per process (7 facets) | 75 µs | ~15 µs |
per readable fd (--ports) |
20 µs | ~6.0 µs |
~3× headroom each — enough for a contended CI box.
A single process-scaled budget failed every CI host it met: containers run few processes + thousands of fds. Calibrate against reality, not your laptop.
Blindness is output, not absence.
U <pid> <facet> <errno> # per-pid unreadable
E <source> <facet> <code> # source-level blindness
L ... unclaimed ... # socket seen; owner out of scope / unreadable
H unclaimed - # unix socket bound; holder out of scope / unreadable
unreadablesection cannot be disabled.- Schema version is the first thing on stdout. Mismatch → loud fail, not zero rows.
- Addresses = raw bytes. No cooked "wildcard" flag. One classifier, your side.
--cpu-timeemits cumulative user+system µs. Never CPU%. Diff two snaps on your clock. We don't sleep 30 ms to invent a rate you didn't ask for.
| flag | emits | notes |
|---|---|---|
--uid |
real uid | name lookup is your problem |
--cwd |
cwd | JSON-encoded field |
--status |
state, nice, threads? | darwin threads may U … status_threads alone |
--argv |
full argv | ≠ short name; JSON-encoded |
--mem / --start-time / --cpu-time |
as named | unreadable → U, not empty |
Failed facet ≠ erase sibling facets. Unreadable cwd never nukes a good uid.
socket-holders PATH [--procs] — who holds one unix socket. A path, not a
pid set, so it is its own verb. --procs is the one facet: it costs holder
names, never the holder set.
H claimed 991 # this pid holds it
H unclaimed - # bound; no readable pid claims it
P 991 1 kaval # --procs
Three answers, kept apart — a reader that spells all three [] is the defect
this verb exists to delete:
| answer | rows | exit |
|---|---|---|
| nobody holds it | none | 0 |
| held, unnameable | H unclaimed - |
0 |
| could not look | E … socket_holders … |
1 |
Linux proves absence (/proc/net/unix lists every bound unix socket).
Darwin cannot — no such table, and Apple gates another user's descriptors —
so a walk that named nobody says E darwin_proc_fds socket_holders BLIND_OR_EMPTY rather than claiming linux's proof.
Path match is exact bytes. No canonicalization, no symlink resolution: the
kernel bound what bind(2) was handed, and a path may contain spaces.
- CPU model nonempty; MHz nullable (Apple Silicon:
null/-, never fake 0). - Disk: total,
bfree,bavail— both free meanings the kernel exposes. - Failed uptime →
E … uptime, omitHUP. Never fabricate boot-age 0.
| result | exit |
|---|---|
some facts + some E/U |
0 (policy is the consumer's) |
no facts, no E (an honest empty answer) |
0 |
E-only / I/O fail |
1 |
| usage — unknown verb, bad flag, missing arg | 2 |
The version line is written on the usage path too, so exit status is what separates a refusal from an answer: only exit 1 carries a document. A consumer that parsed an exit-2 document would read "this binary has no such verb" as "nothing found" — which is what an older binary on a caller's PATH produces every time.
facet vocabulary lives once: Facet in src/schema.rs + facets.json → TS client. Both sides pinned by tests.
BLIND_OR_EMPTY = gated or genuinely empty; platform cannot tell them apart. Same code both OSes.
Same binary. Same questions. Kernel draws different lines.
| Linux | Darwin | |
|---|---|---|
| always visible | name, real uid, RSS, CPU time, start (/proc) |
pid/ppid/uid/state/nice/start/cmd (kern.proc); path via proc_pidpath |
| needs same-uid or root | cwd, fd targets (port + socket-holder attribution) | full view: RSS, CPU, cwd, argv, fd attribution |
| bound unix sockets | /proc/net/unix, world-readable → absence is proof |
no table at all → BLIND_OR_EMPTY, never a claim of absence |
| listeners if owner hidden | TCP table world-readable → unclaimed L |
same-uid fd walk still claims; macOS 27+ gates host-wide PCB list without Apple platform signing |
Darwin extras we refuse to paper over:
psis setuid + Apple-signed +com.apple.system-task-ports.read. We are none of those. (reader, entitlement)- macOS 27: ad-hoc binary gets 48-byte empty PCB list; platform-signed
netstatsees 29 listeners. We reportE darwin_tcp_pcblist ports_unclaimed BLIND_OR_EMPTY, keep same-uid claimed listeners, union both sources. - Layout drift →
E … EINVAL(loud), not a silently short healthy table. - No socket-owning uid on either Darwin source → always
E darwin_listeners ports_uid ENOTSUP; uid column-.
One source can cost several facets; it says so once per facet (e.g. dead kern.proc.all → separate E for uid and status). Host-global constants that fail (CLK_TCK, page size, mach timebase) cost one E, never N per-pid Us.
| consumer | shipped use |
|---|---|
| kolu | terminal-subtree port sensor, padi/kaval memory sampler, start-qualified daemon identity, daemon socket-holder lookup |
| drishti | host process inspection + host telemetry (its own native readers retired) |
| you | --json or the TS client |
What remains is stated as remaining, not as present-tense: this directory has not yet graduated to its own repo.
TS client: client-ts/ → package osfacts-client (no @kolu scope, zero npm runtime deps). Path in: KOLU_OSFACTS_BIN (kolu store).
Former @kolu/port-scan is dead: protocol here, policy in padi, PortInfo fold in @kolu/terminal-vocab.
Three lanes. Three questions. Every push and pull request
(.github/workflows/ci.yml), each on linux and darwin.
- Hermetic
nix build+ tests, both platforms, sandbox. - Park a child (
osfacts-listener) on port 0; assert that pid + that socket under scoped snap. - Self-referential fixtures only. No "host table is empty".
- No
unshare/netns dependency (broke ubuntu-latest; contradicted hermetic claim). - Redact pid/uid/port → placeholders; rest byte-exact.
- Unreadable path: pid 1 (always there, always forbidden).
- Real noisy host · nix-built binary · oracles:
ss(Linux);lsof+listenerscrate plus apsprocess snapshot (Darwin). Darwin takes a secondpssnapshot after the probe and accepts a missing PID only when that process retired or was reused, confirmed byprocreturningESRCH. - Its own CI job (
scripts/live-oracle.sh) — not a phase ofnix build. Sandbox shuts the world out; this lane is the world. - Gherkin scenarios (
cucumber). - Reads the oracle's spelling, not a tidied one:
sswrites a device-bound listener as127.0.0.53%lo, and a row this lane cannot parse is a row it silently does not have. - Live reds block merge on purpose. Advisory reds train people to skip them. OS drift under the tool is exactly when you stop shipping.
client-ts/(osfacts-client):tsc --noEmit+ 39 vitest tests, both platforms.- Not only a parser: the client spawns a child, so the suite writes a real
executable,
chmods it, and pins the errno the OS returns. Same reason as lanes 1 and 2 — the kernels disagree, so both get asked. facets.test.tsreads the repo-rootfacets.json, the same documenttests/v2_contract.rspins to theFacetenum. Add a facet in Rust without adding it here and this lane goes red — not a consumer's runtime parse.- Node + pnpm from the repo's npins pin.
pnpm install --frozen-lockfileagainstclient-ts/pnpm-lock.yaml.
nix shell --impure --expr 'let pkgs = import ./nix/nixpkgs.nix {}; in [ pkgs.nodejs pkgs.pnpm ]' \
-c bash -c 'cd client-ts && pnpm install && pnpm run typecheck && pnpm run test:unit'| in | out (later) |
|---|---|
| OSF1–4, OSF6–8: procs, listeners, socket holders, RSS, start, CPU µs, uid, cwd, status, argv, host telemetry | extraction to its own repo (OSF5's remaining half) |
| every kolu consumer: port sensor, memory sampler, start-qualified daemon identity, socket-holder lookup | |
| drishti adoption: process inspection + host telemetry | |
TSV + --json; mandatory U/E rows |
|
| incubates in kolu monorepo (this dir = future repo root) |
Plan of record (every claim + number measured):
os-facts-tool
License: MIT OR Apache-2.0
Bug reports: don't lie to us and we won't lie to you.