fix(probe): use conservative global task headroom for admission

This commit is contained in:
fy59 2026-10-17 09:01:58 +02:00
parent fcb0ea7f82
commit 7adf77a701
5 changed files with 400 additions and 33 deletions

View file

@ -5,14 +5,18 @@ records the single 2026-10-17 namespace failure: EXPERIMENTAL /
EGL_FX_PROBE_BLOCKED_BEFORE_HELPER / EGL_NOT_RUN / ALLOCATOR_NOT_ATTEMPTED.
The historical READY_FOR_APPROVAL verdict preceded that consumed attempt.
The [task-limit protocol](design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md)
records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE /
OFFLINE_REVIEW_ACCEPTED, with inert validation only. Qualification against the real
manager and new EGL runtime remain NOT_RUN. Separate approval is required for
benign qualification, then for any new EGL attempt; no retry is authorized.
The first benign nominal invocation refused in real-UID accounting before
ATTEMPT or scope creation. The narrowly scoped UID count correction and its
offline/read-only validation are recorded in that protocol; native admission
and parent-death cleanup remain unqualified.
records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE.
Admission now checks inherited NPROC against a trusted host-global task upper
bound before ATTEMPT and immediately before release; the strict real-UID census
remains an optional historical diagnostic. The earlier ENOENT and process-set
change refusals are preserved. The current delta has 99 passing inert tests,
14 unchanged observer tests and one successful read-only preflight; two fresh
sealed LABs pass passive CLI planning without ATTEMPT, scope or payload.
Independent delta review is ACCEPTED; final audit is PASS / ACCEPTED, with no
blocking finding. Disposition: CGROUP_GLOBAL_HEADROOM_READY_FOR_BENIGN_APPROVAL,
source-only. Real manager admission, parent-death cleanup and new EGL runtime
remain NOT_RUN. Separate approval is required for benign qualification, then
for any new EGL attempt; no retry is authorized.
[Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single
V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN,

View file

@ -1,6 +1,8 @@
# STEP20C — EGL/FX task-limit protocol design — 2026-10-17
Status: **EXPERIMENTAL / IMPLEMENTED_OFFLINE / OFFLINE_REVIEW_ACCEPTED**.
Status: **EXPERIMENTAL / IMPLEMENTED_OFFLINE**. Historical offline review is
accepted; independent review of the current global-headroom delta is accepted.
The final audit of this delta is **PASS / ACCEPTED**, with no blocking finding.
New runtime: **NOT_RUN**. No scope, Bubblewrap, EGL/FX helper or compositor is
launched by this mission. The source-only runner now implements the gate;
qualification against the real manager remains separately approval-gated.
@ -77,6 +79,75 @@ manifest/fixture hashes and historical preservation. Disposition:
**CGROUP_UID_COUNT_FIX_READY_FOR_BENIGN_APPROVAL**, source-only. Publication of
the correction does not authorize the three future native qualification commands.
## Conservative global headroom fast path — 2026-10-17
The admission path replaces the fragile all-PID census with the system task
upper bound in field four (`runnable/total`) of Linux `proc_loadavg(5)`.
Every individual real UID's thread count is bounded by that host total at the
sample. The minimum finite inherited NPROC soft/hard limit must be **strictly
greater than total + 256**; infinity is retained, never replaced or raised.
Both read-only preflight (before ATTEMPT) and admission immediately before
release perform this check. Counts may change between checks: no PID-set or
loadavg equality is required, but each observation must independently leave
the full reserve. This is no atomic reservation against future task creation;
the kernel still enforces NPROC and the owned cgroup must still prove exact
finite `pids.max=256`. Mixed-UID helpers do not invalidate the upper bound.
Trust qualification is deliberately Linux-specific and fail-closed: the exact
`/proc` path must be a unique root procfs mount, with no overmount affecting
loadavg. Initial self PID/user namespace identities (`4026531836`/`4026531837`),
the full initial UID map and a singleton self `NSpid` equal to the supervisor PID
are required. PID1 namespace links may be ptrace-protected and are not needed. These
recognized Linux identities are a conservative qualification restriction, not
a portable namespace discovery guarantee. The `/proc` root and `loadavg` must
be root-owned; process metadata such as `self/status` may legitimately be
user-owned. Writable files, symlinks, device/inode drift, malformed or oversized
data, unavailable context or any ambiguous source refuse admission. Reads use bounded no-follow FDs and
qualify the context before and after the sample. Trusted kernel/root mount
administration remains an assumption; hostile root is outside this protocol.
`uid_threads()` remains the strict optional historical diagnostic, outside
admission. The earlier ENOENT and `process creation/end` refusals above remain
historical facts. `ProcFS.identity()`, bootstrap/manager ownership, pidfds,
barrier, cleanup and all cgroup controls retain their contracts. Offline tests
cannot establish `CGROUP_NATIVE_PASS`; real qualification remains **NOT_RUN**.
### Current global-headroom validation — 2026-10-17
The inert Python suite passes **99 tests** (86 historical plus 13 new cases).
The unchanged private parent-death observer independently passes its **14
deterministic tests**. Neither suite launches a real scope or payload.
Exactly one real read-only preflight ran through Desktop Commander in the
ArchASP app.slice context at **2026-10-17T06:57:21.960482+00:00**
(08:57:21 CEST). It passed with inherited NPROC soft/hard **60706/60706**,
one global task sample of **1439**, reserve **256** and observed remaining
margin **59011**. These values describe that sample only; capacity is not
reserved. Only `systemctl show` child commands were allowed. No ATTEMPT,
scope or payload was created; native qualification remains **NOT_RUN**.
Two new private 0700 LABs have distinct nonces and exact sealed source copies,
with **47 file identities / 39 ELF records each**. Their isolated passive CLI
plans pass through Desktop Commander without `launcher_error`, writes,
ATTEMPT, socket or result creation. The historical UID-correction LAB inventories
remain unchanged. Private paths, nonces, process identifiers and raw evidence
are retained outside public documentation.
Independent delta review is **ACCEPTED**, with no blocking finding; it
independently reproduces **99/99** tests and **72 targeted tests**, with AST
invariants and Python style checks passing. The final audit is **PASS / ACCEPTED**,
with no blocking finding: it independently reproduces **99/99 + 14/14** tests,
confirms 13 sensitive symbols/classes unchanged by AST, both LAB inventories
and fixture hashes, identical historical inventories and passive CLI exit 0 /
NOT_RUN. Audited source hashes matched the reviewed-file receipt before this
factual documentation closeout.
Disposition: **CGROUP_GLOBAL_HEADROOM_READY_FOR_BENIGN_APPROVAL**, source-only.
Native qualification remains **NOT_RUN**; no `CGROUP_NATIVE_PASS` follows.
Fresh conversational approval is required before nominal or parent-death
execution; EGL needs another fresh LAB, review and approval. STEP20C remains
NEXT and production multi-output remains NOT_YET_SUPPORTED.
## Evidence, guarantees and admission
| Route / evidence | Risk | Bound actually supplied | Admission condition / disposition |
@ -91,9 +162,10 @@ the correction does not authorize the three future native qualification commands
| Closed mount/environment/device/FD policy | Dlopen drift, inherited socket or control-plane leak | Fixed exposure and sealed inputs, not total resource containment | Preserve pins, ABI, manifest closure and exact allowlists; no new host device/mount |
Linux [RLIMIT_NPROC documentation](https://man7.org/linux/man-pages/man2/getrlimit.2.html)
explains the real-UID thread limit and EAGAIN at creation. A procfs scan is a
refusal diagnostic, not an atomic reservation: disappearing entries, unreadable
status, UID changes or inconsistent thread counts must be reported explicitly.
explains the real-UID thread limit and EAGAIN at creation. The retained optional
procfs scan is a strict diagnostic, not an admission prerequisite or atomic
reservation: disappearing entries, unreadable status, UID changes or inconsistent
thread counts must be reported explicitly.
The [kernel cgroup v2 contract](https://docs.kernel.org/admin-guide/cgroup-v2.html#pid)
counts TIDs hierarchically and rejects task creation over the limit. Migration
can exceed pids.max, so a verified empty private group and controlled admission
@ -132,9 +204,9 @@ Independent code review and native qualification are separate gates:
collecting agent. No cgroup mount, directory FD or user bus reaches payload.
4. After cgroup admission, omit only the explicit NPROC=256 override, retain
the inherited UID soft/hard limits and all other prlimit settings. Record
inherited NPROC, real UID, diagnostic thread-count provenance and effective
cgroup properties; any known lower-than-UID-load NPROC refuses explicitly.
Count uncertainty/drift cannot be accepted as a proof of headroom. No fallback
inherited NPROC, real UID, trusted global task total, full reserve and effective
cgroup properties; minimum finite NPROC <= total + 256 refuses explicitly.
Missing or ambiguous host samples cannot prove headroom. No fallback
to a larger NPROC, unlimited task count, unconfined launch or new device.
5. Reserve the new single attempt durably with O_EXCL before creating its
scope/child or otherwise mutating native execution state. A collision,

View file

@ -17,10 +17,15 @@ nouvelle autorisation conversationnelle avec leur propre LAB et manifeste.
Le premier essai bénin nominal du 17 octobre 2026 a refusé avant ATTEMPT,
création de scope ou payload : `UID process divergent` dans le diagnostic
`ProcFS.uid_threads()`. Les deux LABs bénins historiques restent non consommés
et ne doivent pas être réutilisés. Le comptage corrigé inclut les threads de
et ne doivent pas être réutilisés. Le diagnostic historique corrigé inclut les threads de
tous les processus dont l'UID réel correspond, même avec des UID effectif,
sauvé ou filesystem différents. Il refuse toute incohérence observée ; ce
snapshot ne réserve aucune capacité. `ProcFS.identity()` conserve ses contrôles
sauvé ou filesystem différents. Il reste disponible hors chemin d'admission.
L'admission lit désormais la borne système `runnable/total` de `/proc/loadavg`
après qualification conservative du procfs hôte Linux et de ses namespaces
initiaux ; les contextes ambigus refusent. Le minimum soft/hard NPROC fini doit
être strictement supérieur au total plus256, avant ATTEMPT et avant release.
Les valeurs peuvent fluctuer sans égalité entre snapshots si chaque marge reste
suffisante. Aucun comptage ne réserve atomiquement de capacité. `ProcFS.identity()` conserve ses contrôles
stricts pour le superviseur, le manager et le bootstrap. Les régressions restent
hors ligne ; cette correction n'établit aucun PASS cgroup natif.
@ -135,8 +140,8 @@ le plan : aucun processus, fichier ATTEMPT, socket ni appel manager. Le helper
Python est chargé par chemin absolu, après vérification de son hash, même avec
`python3 -I`. Avec approbation, le préflight refuse un vrai launcher en session
scope, un manager non délégué, app.slice inactif, un montage non cgroup2/domain,
un ancêtre non writable, pids absent, un headroom insuffisant, un diagnostic
UID incertain ou NPROC hérité sous la charge UID. Aucun snapshot /proc ne
un ancêtre non writable, pids absent, un headroom insuffisant, une source
loadavg non fiable ou NPROC hérité <= total système +256. Aucun snapshot /proc ne
constitue une réservation. Aucun processus n'est migré par le superviseur.
Après ATTEMPT durable O_EXCL, systemd-run crée un seul scope et lance le

View file

@ -151,6 +151,91 @@ class ProcFS:
except (OSError, ValueError, KeyError, IndexError) as error:
raise Refusal(f"proc diagnostic illisible/disparu: {error}") from error
def bounded_text(self, relative, limit, root_owned=False):
"""Lire sans suivre le fichier final ; refuser troncature et remplacement."""
path = self.root / relative
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
before = os.fstat(descriptor)
require(stat.S_ISREG(before.st_mode) and not before.st_mode & 0o022,
"source proc modifiable/non régulière")
require(not root_owned or before.st_uid == 0,
"source proc non root")
data = os.read(descriptor, limit + 1)
require(len(data) <= limit and os.read(descriptor, 1) == b"",
"source proc trop longue")
after = path.stat(follow_symlinks=False)
require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino),
"source proc remplacée")
return data.decode("ascii")
finally:
os.close(descriptor)
def host_context(self):
"""Qualifier seulement le procfs hôte Linux reconnu, sinon refuser."""
require(self.root == Path("/proc") and self.root.resolve() == self.root,
"source proc hors chemin hôte")
root = self.root.stat()
require(root.st_uid == 0 and not root.st_mode & 0o022,
"racine proc non fiable")
mounts = self.bounded_text("self/mountinfo", LIMIT)
matches = []
for line in mounts.splitlines():
left, right = line.split(" - ", 1)
fields, source = left.split(), right.split()
require(len(fields) >= 6 and len(source) == 3,
"mountinfo proc invalide")
target = fields[4]
if target == "/proc":
require(fields[3] == "/" and source[0] == "proc"
and source[1] == "proc", "montage proc hôte requis")
matches.append((fields[0], fields[2]))
else:
require(not any(target == name or target.startswith(name + "/")
for name in ("/proc/loadavg", "/proc/self", "/proc/1")),
"surmonture source proc refusée")
require(len(matches) == 1, "montage proc ambigu/absent")
require(matches[0][1] == f"{os.major(root.st_dev)}:{os.minor(root.st_dev)}",
"device proc divergent")
# WHY CONTRACT INVARIANT: recognizing Linux initial self namespace
# inodes avoids confusing two equally nested namespaces with the host.
# PID1 namespace links may be ptrace-protected for ordinary users; the
# singleton self NSpid additionally binds this procfs PID view to us.
namespaces = []
for kind, initial in (("pid", 4026531836), ("user", 4026531837)):
link = os.readlink(self.root / "self" / "ns" / kind)
require(link == f"{kind}:[{initial}]", "namespace proc non initial")
namespaces.append(link)
uid_map = self.bounded_text("self/uid_map", 256).split()
require(uid_map == ["0", "0", "4294967295"], "UID map non initiale")
status = self.bounded_text("self/status", 65536).splitlines()
nspids = [line.split()[1:] for line in status if line.startswith("NSpid:")]
require(nspids == [[str(os.getpid())]], "vue PID proc non hôte")
loadavg = (self.root / "loadavg").stat(follow_symlinks=False)
require(loadavg.st_dev == root.st_dev, "device loadavg divergent")
return (root.st_dev, root.st_ino, tuple(matches), tuple(namespaces),
loadavg.st_ino)
def global_tasks(self):
"""Borne système ponctuelle ; aucun parcours PID ni réservation."""
try:
before = self.host_context()
text = self.bounded_text("loadavg", 256, root_owned=True)
# WHY CONTRACT INVARIANT: bounded ASCII full-match rejects partial,
# extra and malformed fields rather than accepting an undercount.
match = re.fullmatch(
r"[0-9]{1,10}\.[0-9]{2} [0-9]{1,10}\.[0-9]{2} "
r"[0-9]{1,10}\.[0-9]{2} ([0-9]{1,10})/([0-9]{1,10}) "
r"[0-9]{1,10}\n", text)
require(match is not None, "loadavg format invalide")
runnable, total = map(int, match.groups())
require(0 <= runnable <= total and total > 0,
"loadavg compte incohérent")
require(self.host_context() == before, "contexte proc divergent")
return total
except (OSError, UnicodeError, ValueError, IndexError) as error:
raise Refusal(f"source loadavg non fiable/illisible: {error}") from error
def nproc(self):
return resource.getrlimit(resource.RLIMIT_NPROC)
@ -334,6 +419,23 @@ def capacity(cgroups, app):
return records
def nproc_headroom(proc):
"""Vérifier la réserve entière contre la borne système, sans muter NPROC."""
limits = proc.nproc()
require(len(limits) == 2 and all(
isinstance(value, int) and (value == resource.RLIM_INFINITY or value >= 0)
for value in limits), "RLIMIT_NPROC invalide")
total = proc.global_tasks()
require(isinstance(total, int) and total > 0, "charge système invalide")
finite = [value for value in limits if value != resource.RLIM_INFINITY]
# WHY CONTRACT INVARIANT: the system total bounds any real UID, including
# mixed credentials. Strict > reserves TASKS at each sample, never atomically.
require(not finite or min(finite) > total + TASKS,
f"inherited RLIMIT_NPROC insuffisant : {limits}, total={total}, "
f"réserve={TASKS}")
return {"nproc": limits, "global_tasks": total, "required_headroom": TASKS}
def preflight(proc, cgroups, manager, uid, pid, deadline):
"""Toutes les observations précèdent ATTEMPT et la première mutation."""
require(uid != 0 and uid == os.geteuid(), "UID réel/effectif invalide")
@ -360,13 +462,10 @@ def preflight(proc, cgroups, manager, uid, pid, deadline):
controllers, enabled, writable = cgroups.available(app)
require("pids" in controllers and "pids" in enabled, "contrôleur pids absent")
require(writable, "ancêtre commun non writable")
limits = proc.nproc()
load = proc.uid_threads(uid)
require(all(value == resource.RLIM_INFINITY or value >= load for value in limits),
f"inherited RLIMIT_NPROC insuffisant : {limits}, charge UID={load}")
headroom = nproc_headroom(proc)
return {"launcher": launcher, "manager": owner, "manager_process": manager_process,
"mount": mount, "app": app, "uid": uid, "nproc": limits,
"uid_threads_diagnostic": load, "ancestors": capacity(cgroups, app)}
"mount": mount, "app": app, "uid": uid, **headroom,
"ancestors": capacity(cgroups, app)}
def same_lifetime(first, second):
@ -507,12 +606,11 @@ def admit(spec, evidence, pid, proc, cgroups, manager, deadline):
observed_manager = proc.identity(evidence["manager_process"]["pid"])
require(observed_manager == evidence["manager_process"],
"manager PID/startticks divergent")
load = proc.uid_threads(evidence["uid"])
require(proc.nproc() == evidence["nproc"] and all(
value == resource.RLIM_INFINITY or value >= load for value in proc.nproc()),
"NPROC/charge UID divergent avant release")
headroom = nproc_headroom(proc)
require(headroom["nproc"] == evidence["nproc"],
"NPROC hérité divergent avant release")
return {"properties": observed, "process": identity, "cgroup": group,
"kernel": record, "pidfd": pidfd}
"kernel": record, "pidfd": pidfd, "headroom": headroom}
except BaseException:
os.close(pidfd)
raise
@ -679,7 +777,8 @@ def run_scope(lab, manifest, reserve, journal, proc=None, cgroups=None,
# PRECHECK is not journaled until ATTEMPT: failed preflight stays read-only.
reserve(evidence)
phase("PRECHECK", nproc=evidence["nproc"],
uid_threads=evidence["uid_threads_diagnostic"])
global_tasks=evidence["global_tasks"],
required_headroom=evidence["required_headroom"])
barrier = barrier_factory(lab / "gate.sock", spec["nonce"], os.getuid(),
os.getpid(), started + 5)
identity = evidence["launcher"]

View file

@ -63,6 +63,9 @@ class FakeProc:
return dict(self.identities[pid])
def uid_threads(self, uid):
raise AssertionError("diagnostic PID interdit dans admission")
def global_tasks(self):
return self.load
def nproc(self):
@ -347,6 +350,159 @@ class UIDThreadTests(unittest.TestCase):
self.proc.uid_threads(1000)
class GlobalHeadroomTests(unittest.TestCase):
"""Vrai parseur/qualificateur, entrées simulées ; aucun proc hôte lu."""
SAMPLE = "0.00 1.23 12.34 2/1415 1234\n"
def setUp(self):
self.proc = gate.ProcFS()
self.context = patch.object(self.proc, "host_context", return_value=(1, 2))
self.context.start()
self.addCleanup(self.context.stop)
self.reader = patch.object(self.proc, "bounded_text", return_value=self.SAMPLE)
self.read = self.reader.start()
self.addCleanup(self.reader.stop)
def test_actual_controller_observed_example_and_resource_injection(self):
with patch.object(resource, "getrlimit", return_value=(60706, 60706)) as limits:
self.assertEqual(gate.nproc_headroom(self.proc)["global_tasks"], 1415)
limits.assert_called_once_with(resource.RLIMIT_NPROC)
def test_malformed_truncated_extra_or_inconsistent_loadavg_refused(self):
invalid = ("", "0.00 1.23 12.34", self.SAMPLE.rstrip(),
self.SAMPLE + "extra\n", self.SAMPLE.replace("2/1415", "x/1415"),
self.SAMPLE.replace("2/1415", "-1/1415"),
self.SAMPLE.replace("2/1415", "0/0"),
self.SAMPLE.replace("2/1415", "1416/1415"),
self.SAMPLE.replace("2/1415", "2/" + "9" * 30))
for text in invalid:
with self.subTest(text=text):
self.read.return_value = text
with self.assertRaises(gate.Refusal):
self.proc.global_tasks()
def test_zero_runnable_is_valid(self):
self.read.return_value = self.SAMPLE.replace("2/1415", "0/1415")
self.assertEqual(self.proc.global_tasks(), 1415)
def test_missing_unreadable_nonascii_or_changed_context_refuses(self):
for error in (FileNotFoundError("missing"), PermissionError("denied"),
UnicodeError("non ascii")):
with self.subTest(error=error), patch.object(
self.proc, "bounded_text", side_effect=error):
with self.assertRaises(gate.Refusal):
self.proc.global_tasks()
with patch.object(self.proc, "host_context", side_effect=[(1,), (2,)]):
with self.assertRaisesRegex(gate.Refusal, "contexte"):
self.proc.global_tasks()
def test_finite_minimum_requires_strict_full_reserve(self):
for limits in ((1671, 60706), (60706, 1671), (1415, 1415), (0, 0),
(resource.RLIM_INFINITY, 1671), (1671, resource.RLIM_INFINITY)):
with self.subTest(limits=limits), patch.object(
self.proc, "nproc", return_value=limits):
with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC"):
gate.nproc_headroom(self.proc)
for limits in ((1672, 60706), (resource.RLIM_INFINITY, 1672),
(resource.RLIM_INFINITY, resource.RLIM_INFINITY)):
with self.subTest(limits=limits), patch.object(
self.proc, "nproc", return_value=limits):
self.assertEqual(gate.nproc_headroom(self.proc)["nproc"], limits)
def test_bounded_fd_reader_rejects_symlink_and_oversize(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
proc = gate.ProcFS(root)
(root / "data").write_text("x" * 257)
with self.assertRaises(gate.Refusal):
proc.bounded_text("data", 256)
(root / "link").symlink_to(root / "data")
with self.assertRaises(OSError):
proc.bounded_text("link", 256)
def test_fd_reader_rejects_writable_foreign_or_replaced_source(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
path = root / "data"
path.write_text(self.SAMPLE)
proc = gate.ProcFS(root)
path.chmod(0o666)
with self.assertRaisesRegex(gate.Refusal, "modifiable"):
proc.bounded_text("data", 256)
path.chmod(0o444)
info = path.stat()
foreign = list(info)
foreign[4] = 1001
with patch.object(gate.os, "fstat", return_value=os.stat_result(foreign)):
with self.assertRaisesRegex(gate.Refusal, "non root"):
proc.bounded_text("data", 256, root_owned=True)
replaced = list(info)
replaced[1] += 1
with patch.object(Path, "stat", return_value=os.stat_result(replaced)):
with self.assertRaisesRegex(gate.Refusal, "remplacée"):
proc.bounded_text("data", 256)
def test_invalid_inherited_limits_refuse(self):
for limits in ((-2, 4096), ("4096", 4096), (4096,)):
with self.subTest(limits=limits), patch.object(
self.proc, "nproc", return_value=limits):
with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC invalide"):
gate.nproc_headroom(self.proc)
def test_actual_host_qualification_refuses_untrusted_contexts(self):
# Stop parser-level mocks: exercise the actual context qualification.
self.context.stop()
device = os.makedev(0, 5)
info = os.stat_result((0o40555, 10, device, 1, 0, 0, 0, 0, 0, 0))
mount = "1 0 0:5 / /proc rw - proc proc rw\n"
def text(name, *_args, **_kwargs):
if name == "self/mountinfo":
return mount
if name == "self/status":
return f"Name: fixture\nNSpid: {PID}\n"
return "0 0 4294967295\n"
def namespace(path):
return ("pid:[4026531836]" if path.name == "pid"
else "user:[4026531837]")
with (patch.object(Path, "stat", return_value=info),
patch.object(Path, "resolve", lambda path: path),
patch.object(self.proc, "bounded_text", side_effect=text),
patch.object(gate.os, "readlink", side_effect=namespace)):
self.proc.host_context()
for bad in (mount.replace(" - proc proc", " - fuse.lxcfs lxcfs"),
mount + mount,
mount.replace(" / /proc ", " /sub /proc "),
mount + "2 1 0:5 / /proc/loadavg rw - proc proc rw\n"):
with self.subTest(mount=bad):
original, mount = mount, bad
with self.assertRaises(gate.Refusal):
self.proc.host_context()
mount = original
with patch.object(gate.os, "readlink", return_value="pid:[123]"):
with self.assertRaisesRegex(gate.Refusal, "namespace"):
self.proc.host_context()
with patch.object(gate.os, "readlink", side_effect=PermissionError("self")):
with self.assertRaises(PermissionError):
self.proc.host_context()
for nspid in ("", f"NSpid: {PID} 1\n", f"NSpid: {PID + 1}\n",
f"NSpid: {PID}\nNSpid: {PID}\n"):
with self.subTest(nspid=nspid), patch.object(
self.proc, "bounded_text", side_effect=lambda name, *_a:
nspid if name == "self/status" else text(name)):
with self.assertRaisesRegex(gate.Refusal, "vue PID"):
self.proc.host_context()
with patch.object(self.proc, "bounded_text", side_effect=lambda name, *_a:
mount if name == "self/mountinfo" else "0 1000 1\n"):
with self.assertRaisesRegex(gate.Refusal, "UID map"):
self.proc.host_context()
with self.assertRaisesRegex(gate.Refusal, "chemin hôte"):
gate.ProcFS(Path("/tmp/proc")).host_context()
class AdmissionTests(unittest.TestCase):
def setUp(self):
self.sentinel = patch.object(
@ -400,6 +556,37 @@ class AdmissionTests(unittest.TestCase):
"PAYLOAD_STARTED", "CLEANED"])
self.assertEqual(self.proc.limits, (4096, 4096))
def test_global_load_fluctuation_does_not_require_pid_census(self):
with patch.object(self.proc, "global_tasks", side_effect=[1415, 1500]):
result, _ = self.execute()
self.assertEqual(result["cause"], "exit")
self.assertEqual(self.barrier.released, 1)
self.assertEqual(self.reservations[0]["global_tasks"], 1415)
def test_rising_global_load_refuses_release(self):
with patch.object(self.proc, "global_tasks", side_effect=[1415, 3840]):
result, _ = self.execute()
self.assertIn("RLIMIT_NPROC", result["error"])
self.assertEqual(self.barrier.released, 0)
self.assertEqual(len(self.reservations), 1)
def test_failed_capacity_preflight_has_no_attempt_or_native_spawn(self):
self.proc.load = 4096
result, _ = self.execute()
self.assertIn("RLIMIT_NPROC", result["error"])
self.assertEqual(list(self.lab.iterdir()), [])
self.assertEqual(self.reservations, [])
self.assertEqual(self.journal, [])
self.assertEqual(self.manager.created, 0)
def test_changed_inherited_nproc_before_release_refuses(self):
evidence = self.preflight()
self.cgroups.present = True
self.proc.limits = (5000, 5000)
with self.assertRaisesRegex(gate.Refusal, "hérité divergent"):
gate.admit(SPEC, evidence, 43, self.proc, self.cgroups, self.manager,
time.monotonic() + 1)
def test_session_scope_refused_before_reservation(self):
self.proc.identities[PID]["cgroup"] = (
f"/user.slice/user-{UID}.slice/session-6.scope")
@ -545,7 +732,7 @@ class AdmissionTests(unittest.TestCase):
def test_failures_in_preflight_reservation_admission_and_drain(self):
for error in (OSError("read failure"), KeyboardInterrupt()):
with patch.object(self.proc, "uid_threads", side_effect=error):
with patch.object(self.proc, "global_tasks", side_effect=error):
result, _ = self.execute()
self.assertNotEqual(result["cause"], "exit")
self.assertEqual(self.manager.created, 0)