The convention that tunables live in system/parameters.zig with their reasoning attached predates this and got 2% compliance — 5 of 235. A convention with no teeth is how a bare `const maximum_devices = 64` reached an AMD desktop and cost it USB and storage. This is the same rule with a gate behind it. tools/check-bounds.py finds every bound-shaped declaration — a `maximum_*` const with a literal value, or a type with a literal array length — and requires the five-field block above it: what it counts, who decides its size, what it protects, what happens at the limit, and how anyone finds out. The at-limit vocabulary is closed: refuse, degrade, truncate, grow. There is deliberately no way to spell "silent", no way to spell "drop", and nothing meaning "allow", so the behaviours that did the damage cannot be written down. Truncation is legal only carrying a marker the reader can see, which is why klog_maximum_message qualifies and a USB descriptor cut at 512 bytes does not. An array length that names a declared bound is not itself a bound; only literal lengths are flagged, which pushes ceilings toward having names. The 273 that predate the rule are allowlisted so this lands without a tree-wide sweep in front of it, and that list may only shrink: declaring a bound means deleting its line, and the check fails on a stale entry too. Nothing may be added. Wired into `zig build test` and available alone as `zig build bounds`. Not in the default build — it reads the whole tree, and a red bounds check should not stop you booting a kernel. Five are now declared rather than allowlisted. Writing them out is its own argument: maximum_devices reads "protects: nothing — this is a sizing guess about someone else's computer", and maximum_tasks now carries the fact that it has been raised twice, each time by something that outgrew it. Verified the gate refuses an undeclared bound, a declared one using forbidden vocabulary, and an allowlist entry that has since been declared. Suite 115/115.
190 lines
8.1 KiB
Python
190 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Every compile-time ceiling states what it is doing there.
|
|
|
|
A *bound* is a number chosen at compile time that decides how much of something the
|
|
code can hold: `const maximum_devices = 64`, `var below: [64]Range`, `var blob: [512]u8`.
|
|
Different units, one shape, and one recurring way of going wrong — see
|
|
docs/fixed-bounds-audit.md, where 235 of them turned up, 139 on quantities the machine
|
|
or a file decides rather than us, and 171 silent when reached.
|
|
|
|
This is the gate that keeps new ones from joining them. It does not resize anything and
|
|
it makes no judgement about whether a bound should exist; it only refuses one that will
|
|
not say what it is for. The declaration is a doc comment immediately above:
|
|
|
|
/// bound: logical CPUs the kernel tracks
|
|
/// decided-by: hardware
|
|
/// protects: the per-CPU bookkeeping arrays, which are sized at compile time
|
|
/// at-limit: degrade - surplus cores are left parked, never brought online
|
|
/// observed-by: platform.cpusDropped() -> the WARNING at kernel.zig:281
|
|
pub const maximum_cpus = 128;
|
|
|
|
`decided-by` is the field the audit turned on: `hardware` and `external` mean the
|
|
quantity is not ours to choose, and a fixed bound on one of those is a defect rather
|
|
than a tunable. `at-limit`'s vocabulary is closed on purpose — there is no `silent`,
|
|
no `drop`, and nothing meaning *allow*, so the behaviours that caused the damage cannot
|
|
be written down. `truncate` is legal only with a marker the reader can see.
|
|
|
|
An array length that names a declared bound (`[maximum_devices]Descriptor`) is not
|
|
itself a bound: the number lives at the declaration, and that is where it is declared.
|
|
Only literal lengths are flagged, which pushes ceilings toward having names.
|
|
|
|
The ~235 that already exist are listed in tools/bounds-allowlist.txt so this can land
|
|
without a tree-wide sweep in front of it. That list may only shrink: declaring a bound
|
|
means deleting its line, and a stale line is an error too.
|
|
|
|
Usage: check-bounds.py [--list] [repo-root]
|
|
--list print every undeclared bound found, for regenerating the allowlist
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Directories worth gating. `test/` is excluded: a fixture's `[100]MemoryRegion` is a
|
|
# test input, not a ceiling the system runs into.
|
|
ROOTS = ("system", "library", "boot")
|
|
SKIP_PARTS = {".zig-cache", "zig-out", ".git", ".claude", "vendor", "generated"}
|
|
SKIP_FILES = {"tests.zig"}
|
|
|
|
FIELDS = ("bound", "decided-by", "protects", "at-limit", "observed-by")
|
|
DECIDED_BY = {"hardware", "external", "ours"}
|
|
AT_LIMIT = {"refuse", "degrade", "truncate", "grow"}
|
|
|
|
# A `const` whose name reads like a ceiling and whose value is an integer literal.
|
|
NAMED = re.compile(
|
|
r"^\s*(?:pub\s+)?const\s+([A-Za-z_]\w*)\s*(?::\s*[\w.\[\]]+\s*)?=\s*"
|
|
r"(\d[\d_]*|0x[0-9a-fA-F_]+)\s*(?:\*\s*\d[\d_]*\s*)*;"
|
|
)
|
|
NAME_IS_BOUND = re.compile(r"(^|_)(maximum|max|limit|capacity|depth|attempts|count)($|_)", re.I)
|
|
|
|
# A declaration or struct field whose type carries a *literal* array length.
|
|
ARRAY_DECL = re.compile(r"^\s*(?:pub\s+)?(?:const|var)\s+([A-Za-z_]\w*)\s*:[^=]*?\[\s*(\d[\d_]*)\s*\]")
|
|
ARRAY_FIELD = re.compile(r"^\s*([A-Za-z_]\w*)\s*:\s*\[\s*(\d[\d_]*)\s*\]")
|
|
|
|
# Struct padding and reserved fields are shapes, not ceilings — nothing is ever "held"
|
|
# in them. Everything else with a literal length is a candidate, *including* the tidy
|
|
# powers of two: `[512]u8` and `[64]Range` were the two worst findings in the audit, and
|
|
# any size-based exemption would have skipped exactly them. A length that is genuinely a
|
|
# fact rather than a ceiling says so in its declaration ("decided-by: hardware, the PCI
|
|
# spec gives a function 6 BARs") — that is what the declaration is for.
|
|
NOT_A_BOUND_NAME = re.compile(r"^_*(padding|pad|reserved|unused|spare)\d*$", re.I)
|
|
|
|
|
|
def sources(root: Path):
|
|
for top in ROOTS:
|
|
base = root / top
|
|
if not base.is_dir():
|
|
continue
|
|
for path in sorted(base.rglob("*.zig")):
|
|
if SKIP_PARTS & set(path.parts) or path.name in SKIP_FILES:
|
|
continue
|
|
yield path
|
|
|
|
|
|
def declaration_above(lines, index):
|
|
"""The `/// key: value` block immediately above line `index`, as a dict."""
|
|
fields = {}
|
|
i = index - 1
|
|
while i >= 0:
|
|
stripped = lines[i].strip()
|
|
if not stripped.startswith("///"):
|
|
break
|
|
body = stripped[3:].strip()
|
|
match = re.match(r"([a-z-]+):\s*(.+)", body)
|
|
if match:
|
|
fields[match.group(1)] = match.group(2).strip()
|
|
i -= 1
|
|
return fields
|
|
|
|
|
|
def problems_with(fields):
|
|
"""Why a declaration is not acceptable, or an empty list."""
|
|
missing = [f for f in FIELDS if f not in fields or not fields[f]]
|
|
if missing:
|
|
return ["missing " + ", ".join(missing)]
|
|
out = []
|
|
if fields["decided-by"] not in DECIDED_BY:
|
|
out.append(f"decided-by must be one of {sorted(DECIDED_BY)}, not {fields['decided-by']!r}")
|
|
verb = fields["at-limit"].split()[0].strip("-:,").lower()
|
|
if verb not in AT_LIMIT:
|
|
out.append(
|
|
f"at-limit must start with one of {sorted(AT_LIMIT)}, not {verb!r}. "
|
|
"There is deliberately no way to say 'silent', 'drop', or anything meaning 'allow'"
|
|
)
|
|
if verb == "truncate" and len(fields["at-limit"].split()) < 3:
|
|
out.append("at-limit: truncate must say how a reader can TELL it happened")
|
|
return out
|
|
|
|
|
|
def find(root: Path):
|
|
"""Every bound-shaped declaration: (relative path, name, value, line, fields)."""
|
|
for path in sources(root):
|
|
rel = path.relative_to(root).as_posix()
|
|
lines = path.read_text(encoding="utf-8", errors="replace").split("\n")
|
|
for n, line in enumerate(lines):
|
|
if line.lstrip().startswith("//"):
|
|
continue
|
|
name = value = None
|
|
m = NAMED.match(line)
|
|
if m and NAME_IS_BOUND.search(m.group(1)):
|
|
name, value = m.group(1), m.group(2)
|
|
else:
|
|
m = ARRAY_DECL.match(line) or ARRAY_FIELD.match(line)
|
|
if m and not NOT_A_BOUND_NAME.match(m.group(1)):
|
|
name, value = m.group(1), m.group(2)
|
|
if name:
|
|
yield rel, name, value, n + 1, declaration_above(lines, n)
|
|
|
|
|
|
def main():
|
|
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
listing = "--list" in sys.argv
|
|
root = Path(argv[0]) if argv else Path(__file__).resolve().parent.parent
|
|
|
|
allow_path = root / "tools" / "bounds-allowlist.txt"
|
|
allowed = set()
|
|
if allow_path.exists():
|
|
for raw in allow_path.read_text().split("\n"):
|
|
entry = raw.split("#", 1)[0].strip()
|
|
if entry:
|
|
allowed.add(entry)
|
|
|
|
undeclared, bad, seen = [], [], set()
|
|
for rel, name, value, line, fields in find(root):
|
|
key = f"{rel}:{name}"
|
|
seen.add(key)
|
|
if not fields:
|
|
(undeclared if key not in allowed else []).append((key, value, line))
|
|
continue
|
|
for why in problems_with(fields):
|
|
bad.append((key, line, why))
|
|
if key in allowed and not problems_with(fields):
|
|
bad.append((key, line, "now declared — delete its line from tools/bounds-allowlist.txt"))
|
|
|
|
if listing:
|
|
for key, value, line in sorted(undeclared):
|
|
print(f"{key} # = {value}, line {line}")
|
|
for key in sorted(allowed - seen):
|
|
print(f"# STALE: {key}")
|
|
return 0
|
|
|
|
stale = sorted(allowed - seen)
|
|
if not undeclared and not bad and not stale:
|
|
return 0
|
|
|
|
print("bounds check failed\n", file=sys.stderr)
|
|
for key, value, line in sorted(undeclared):
|
|
print(f" {key} (= {value}, line {line})", file=sys.stderr)
|
|
print(" no declaration. A ceiling states what it counts, who decides its", file=sys.stderr)
|
|
print(" size, what it protects, what happens at the limit, and how you", file=sys.stderr)
|
|
print(" find out. See docs/os-development/bounds.md.", file=sys.stderr)
|
|
for key, line, why in sorted(bad):
|
|
print(f" {key} (line {line}): {why}", file=sys.stderr)
|
|
for key in stale:
|
|
print(f" {key}: allowlisted but no longer found — delete its line", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|