feat(build): no fork in multi-threaded XCE processes, enforced by check-no-fork
Design constraint (tech.md "Process Creation"): the controller, scheduler,
xcecd and anything that starts libxce threads never call fork, vfork,
forkpty, daemon, system, popen or uv_spawn; children start with posix_spawn()
or a systemd unit. A process proven single-threaded may fork, marked
/* xce-fork-ok: <why> */. Tests, harness, autoscaler (not finalized; bug-1969)
and the Go frontend are out of scope.
- scripts/check-no-fork.py scans src/{controller,scheduler,lib,worker}
(comment/string/#if 0 aware); runs as meson test "no-fork rule" in the
lib/controller/scheduler/worker suites and in the pre-commit hook.
- worker: drop xcecd's unused fork-based worker start paths
(process_manager_start_process, process_manager_start_worker; xcecd
starts xcecd_worker over D-Bus) and a dead #if 0 daemonize fork.
- xcecd_worker's fork/uv_spawn/system calls are marked: its call graph
from main(), callbacks included, reaches no pthread_create or libuv
thread-pool call.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4fee1077c
commit
6dd70f1091
15 changed files with 182 additions and 444 deletions
131
scripts/check-no-fork.py
Normal file
131
scripts/check-no-fork.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Enforce the no-fork rule (.kiro/steering/tech.md, "Process creation").
|
||||
|
||||
XCE daemons are multi-threaded (libxce delaymgr/netio/vtaskmgr threads, xcecd's
|
||||
thread pool); fork() in a multi-threaded process copies only the calling thread,
|
||||
so any lock another thread held (malloc, logger, stdio) stays locked in the
|
||||
child. Component code therefore never calls fork-family functions; children are
|
||||
started with posix_spawn(), or by systemd over D-Bus.
|
||||
|
||||
Scanned: C sources of the components the rule covers (COVERED below), minus
|
||||
test code. Excluded by decision (2026-10-10): tests, the harness (xce_test),
|
||||
the autoscaler (not finalized; tracked as a bug) and the Go frontend.
|
||||
|
||||
A call is allowed only in a process proven single-threaded, marked on the call
|
||||
line or the line above with: /* xce-fork-ok: <why it is single-threaded> */
|
||||
|
||||
Usage: check-no-fork.py [repo-root] [file ...] (exit 1 on a violation)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
COVERED = ["src/controller", "src/scheduler", "src/lib", "src/worker"]
|
||||
EXCLUDED_DIRS = {"testsuite", "test", "tests"}
|
||||
# Fork-family calls. uv_spawn() is libuv's fork()+exec on Linux.
|
||||
BANNED = re.compile(
|
||||
r"(?<![\w.>])(fork|vfork|forkpty|daemon|system|popen|uv_spawn)\s*\(")
|
||||
MARK = re.compile(r"xce-fork-ok:\s*\S")
|
||||
|
||||
|
||||
def strip_code(text):
|
||||
"""Blank comments and string/char literals, keeping line structure, so
|
||||
names inside them never match. Returns the stripped text."""
|
||||
out, i, n = [], 0, len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if text.startswith("/*", i):
|
||||
j = text.find("*/", i + 2)
|
||||
j = n if j < 0 else j + 2
|
||||
out.append(re.sub(r"[^\n]", " ", text[i:j]))
|
||||
i = j
|
||||
elif text.startswith("//", i):
|
||||
j = text.find("\n", i)
|
||||
j = n if j < 0 else j
|
||||
out.append(" " * (j - i))
|
||||
i = j
|
||||
elif c in "\"'":
|
||||
j = i + 1
|
||||
while j < n and text[j] != c and text[j] != "\n":
|
||||
j += 2 if text[j] == "\\" else 1
|
||||
j = min(j + 1, n)
|
||||
out.append(c + " " * (j - i - 2) + c if j - i >= 2 else c)
|
||||
i = j
|
||||
else:
|
||||
out.append(c)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def disabled_lines(lines):
|
||||
"""Line indexes inside `#if 0` blocks (nesting aware)."""
|
||||
dead, depth = set(), 0
|
||||
for k, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
if depth:
|
||||
if re.match(r"#\s*if", s):
|
||||
depth += 1
|
||||
elif re.match(r"#\s*endif", s):
|
||||
depth -= 1
|
||||
elif depth == 1 and re.match(r"#\s*(else|elif)", s):
|
||||
depth = 0
|
||||
dead.add(k)
|
||||
elif re.match(r"#\s*if\s+0\b", s):
|
||||
depth = 1
|
||||
dead.add(k)
|
||||
return dead
|
||||
|
||||
|
||||
def check_file(path, rel):
|
||||
raw = open(path, encoding="utf-8", errors="replace").read()
|
||||
raw_lines = raw.split("\n")
|
||||
code_lines = strip_code(raw).split("\n")
|
||||
dead = disabled_lines(raw_lines)
|
||||
bad = []
|
||||
for k, line in enumerate(code_lines):
|
||||
if k in dead:
|
||||
continue
|
||||
for m in BANNED.finditer(line):
|
||||
prev = raw_lines[k - 1] if k else ""
|
||||
if MARK.search(raw_lines[k]) or MARK.search(prev):
|
||||
continue
|
||||
bad.append(f"{rel}:{k + 1}: {m.group(1)}() "
|
||||
f"{raw_lines[k].strip()}")
|
||||
return bad
|
||||
|
||||
|
||||
def covered_files(root):
|
||||
for top in COVERED:
|
||||
for d, dirs, files in os.walk(os.path.join(root, top)):
|
||||
dirs[:] = [x for x in dirs if x not in EXCLUDED_DIRS
|
||||
and not x.startswith(".")]
|
||||
for f in files:
|
||||
if f.endswith((".c", ".h")) and not f.startswith("test"):
|
||||
yield os.path.join(d, f)
|
||||
|
||||
|
||||
def main(argv):
|
||||
root = os.path.abspath(argv[1]) if len(argv) > 1 else os.getcwd()
|
||||
if len(argv) > 2:
|
||||
files = [os.path.join(root, f) for f in argv[2:]]
|
||||
covered = set(map(os.path.abspath, covered_files(root)))
|
||||
files = [f for f in files if os.path.abspath(f) in covered]
|
||||
else:
|
||||
files = list(covered_files(root))
|
||||
bad = []
|
||||
for f in sorted(files):
|
||||
bad += check_file(f, os.path.relpath(f, root))
|
||||
if bad:
|
||||
print("no-fork rule violated (.kiro/steering/tech.md, "
|
||||
"\"Process creation\"):")
|
||||
print("\n".join(" " + b for b in bad))
|
||||
print("Use posix_spawn() (or a systemd unit over D-Bus). If the process "
|
||||
"is proven single-threaded, mark the call with "
|
||||
"/* xce-fork-ok: <why> */.")
|
||||
return 1
|
||||
print(f"no-fork rule: {len(files)} file(s) OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Loading…
Add table
Add a link
Reference in a new issue