[verified] Align broker fixtures with descriptor-relative socket paths

This commit is contained in:
2026-10-11 17:27:21 +00:00
parent 5c3bffaf71
commit d47df21578
2 changed files with 286 additions and 26 deletions
+8
View File
@@ -21,6 +21,14 @@ trees under `/opt/data/gitea-agent/harness/tests/{common,spec,impl,review}`.
This document is owned by the canonical harness tests and must be updated in This document is owned by the canonical harness tests and must be updated in
place when their supported discovery contract or command paths change. place when their supported discovery contract or command paths change.
Broker fixture contract: in-process broker fixtures bind and connect through
`/proc/self/fd/{parent_fd}/{socket_name}`, matching the production broker. Long
UTF-8 roots and socket paths are supported; the fixture must not shorten them or
reject them merely because the conventional Unix-domain path encoding exceeds
107 bytes. Fixture setup failures clean up only resources they own, preserve
foreign/replaced paths, close every client and directory descriptor, and retain
unauthenticated-token rejection behavior.
It validates all `test*.py` files recursively under exactly these required It validates all `test*.py` files recursively under exactly these required
roles: `common`, `spec`, `impl`, and `review`. `harness/tests`, every role, and roles: `common`, `spec`, `impl`, and `review`. `harness/tests`, every role, and
every nested test package must have an `__init__.py`; every role and every every nested test package must have an `__init__.py`; every role and every
+277 -25
View File
@@ -70,14 +70,19 @@ class ManagedLockBrokerTests(unittest.TestCase):
if token is not None: if token is not None:
data["token"] = token data["token"] = token
import socket import socket
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: parent_fd, name = broker._open_parent(Path(data["socket"]))
s.connect(data["socket"]) try:
payload = {"action": action, "token": data["token"] if token is None else token} with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
if ttl is not None: s.settimeout(3)
payload["ttl_seconds"] = ttl s.connect(f"/proc/self/fd/{parent_fd}/{name}")
s.sendall((json.dumps(payload) + "\n").encode()) payload = {"action": action, "token": data["token"] if token is None else token}
with s.makefile("rb") as response_file: if ttl is not None:
return json.loads(response_file.readline()) payload["ttl_seconds"] = ttl
s.sendall((json.dumps(payload) + "\n").encode())
with s.makefile("rb") as response_file:
return json.loads(response_file.readline())
finally:
os.close(parent_fd)
def test_constructor_rejects_lexical_root_and_handle_aliases(self): def test_constructor_rejects_lexical_root_and_handle_aliases(self):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
@@ -255,7 +260,8 @@ class ManagedLockBrokerTests(unittest.TestCase):
proc.terminate(); proc.wait(timeout=3) proc.terminate(); proc.wait(timeout=3)
proc.stdout.close(); proc.stderr.close() proc.stdout.close(); proc.stderr.close()
def make_bound_instance(self, root): def make_bound_instance(self, root, *, fail_stage=None, captured=None, stages=None, failure=None):
"""Build a real fixture, cleaning up and re-raising setup failures."""
import socket import socket
ensure_cache(root) ensure_cache(root)
locks = root / "repositories/alice/ledger/locks" locks = root / "repositories/alice/ledger/locks"
@@ -265,16 +271,158 @@ class ManagedLockBrokerTests(unittest.TestCase):
instance = broker.Broker(Namespace(root=str(root), owner="alice", repository="ledger", issue=7, instance = broker.Broker(Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="cleanup-test", handle=str(runtime / "broker.handle"), role="gitea-impl-agent", run_id="cleanup-test", handle=str(runtime / "broker.handle"),
socket=str(runtime / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0)) socket=str(runtime / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0))
instance.set.issue.acquire() if captured is not None:
broker._write_handle(instance.handle, instance.state, exclusive=True, captured.append(instance)
on_created=instance._record_handle_identity) observed = stages if stages is not None else {}
instance.handle_created = True; instance.started = True try:
instance.socket_parent_fd = broker._open_directory(runtime) instance.set.issue.acquire()
instance.server = socket.socket(socket.AF_UNIX) observed["issue_acquired"] = instance.set.issue.held
instance.server.bind(str(instance.sock_path)) if fail_stage == "after-issue":
st = instance.sock_path.lstat() raise RuntimeError("sentinel after issue acquire")
instance.socket_identity = (st.st_dev, st.st_ino) broker._write_handle(instance.handle, instance.state, exclusive=True,
return instance on_created=instance._record_handle_identity)
instance.handle_created = True; instance.started = True
observed["handle_created"] = instance.handle_created and instance.handle_identity is not None
if fail_stage == "after-handle":
raise RuntimeError("sentinel after handle creation")
instance.socket_parent_fd = broker._open_directory(runtime)
observed["parent_fd_assigned"] = instance.socket_parent_fd is not None
if fail_stage == "after-parent-fd":
raise RuntimeError("sentinel after parent fd ownership")
instance.server = socket.socket(socket.AF_UNIX)
if fail_stage == "socket-constructor":
raise RuntimeError("sentinel socket constructor failure")
instance.server.bind(f"/proc/self/fd/{instance.socket_parent_fd}/{instance.sock_path.name}")
st = os.stat(instance.sock_path.name, dir_fd=instance.socket_parent_fd, follow_symlinks=False)
instance.socket_identity = (st.st_dev, st.st_ino)
observed["socket_bound"] = instance.socket_identity is not None
if fail_stage == "post-bind":
raise RuntimeError("sentinel after socket identity")
return instance
except BaseException as original:
if failure is not None:
failure.append(original)
try:
instance.cleanup("released")
except BaseException as cleanup_error:
original.add_note(f"fixture cleanup failed: {type(cleanup_error).__name__}")
raise
def assert_failed_fixture_cleanup(self, instance, stages, failure, original):
self.assertIsNotNone(instance)
self.assertIs(failure[0], original)
self.assertTrue(stages["issue_acquired"])
self.assertTrue(probe_free(instance.set.issue.path))
self.assertTrue(probe_free(instance.set.repository.path))
self.assertIsNone(instance.socket_parent_fd)
self.assertIsNone(instance.server)
self.assertTrue(instance.handle_created)
terminal = json.loads(instance.handle.read_text())
self.assertNotIn("token", terminal)
self.assertIn(terminal["status"], ("released", "cleanup-incomplete"))
def test_fixture_partial_handle_write_failure_releases_owned_lease(self):
captured = []
writes = []
original_write = broker._write_all
sentinel = OSError("sentinel partial fixture handle write")
def partial_write(fd, data):
if not writes:
writes.append(os.write(fd, data[:1]))
raise sentinel
return original_write(fd, data)
with tempfile.TemporaryDirectory() as d:
try:
with patch.object(broker, "_write_all", side_effect=partial_write):
with self.assertRaises(OSError) as raised:
self.make_bound_instance(Path(d), captured=captured)
self.assertIs(raised.exception, sentinel)
self.assertEqual(writes, [1], "partial write was not exercised")
instance = captured[0]
self.assertTrue(instance.handle_created)
self.assertTrue(probe_free(instance.set.issue.path), "fixture leaked its acquired lease")
self.assertIsNone(instance.socket_parent_fd)
self.assertIsNone(instance.server)
terminal = json.loads(instance.handle.read_text())
self.assertNotIn("token", terminal)
self.assertEqual(terminal["status"], "released")
finally:
if captured:
captured[0].cleanup("released")
def test_fixture_failure_after_handle_creation_preserves_original_and_cleans(self):
captured, stages, failure = [], {}, []
instance = None
with tempfile.TemporaryDirectory() as d:
try:
with self.assertRaisesRegex(RuntimeError, "sentinel after handle creation") as raised:
self.make_bound_instance(Path(d), fail_stage="after-handle",
captured=captured, stages=stages, failure=failure)
instance = captured[0]
self.assert_failed_fixture_cleanup(instance, stages, failure, raised.exception)
self.assertTrue(stages["handle_created"])
self.assertFalse(stages.get("parent_fd_assigned", False))
finally:
if instance is not None:
instance.cleanup("released")
def test_fixture_socket_constructor_failure_closes_owned_parent_fd(self):
captured, stages, failure = [], {}, []
instance = None
with tempfile.TemporaryDirectory() as d:
try:
with patch("socket.socket", side_effect=RuntimeError("sentinel socket constructor failure")):
with self.assertRaisesRegex(RuntimeError, "sentinel socket constructor failure") as raised:
self.make_bound_instance(Path(d), captured=captured, stages=stages, failure=failure)
instance = captured[0]
self.assert_failed_fixture_cleanup(instance, stages, failure, raised.exception)
self.assertTrue(stages["parent_fd_assigned"])
self.assertFalse(stages.get("socket_bound", False))
finally:
if instance is not None:
instance.cleanup("released")
def test_fixture_post_bind_failure_removes_only_captured_socket(self):
captured, stages, failure = [], {}, []
instance = None
with tempfile.TemporaryDirectory() as d:
try:
with self.assertRaisesRegex(RuntimeError, "sentinel after socket identity") as raised:
self.make_bound_instance(Path(d), fail_stage="post-bind",
captured=captured, stages=stages, failure=failure)
instance = captured[0]
self.assert_failed_fixture_cleanup(instance, stages, failure, raised.exception)
self.assertTrue(stages["parent_fd_assigned"])
self.assertTrue(stages["socket_bound"])
self.assertFalse(instance.sock_path.exists())
finally:
if instance is not None:
instance.cleanup("released")
def test_fixture_cleanup_diagnostic_does_not_mask_setup_failure(self):
captured, stages, failure = [], {}, []
instance = None
real_cleanup = broker.Broker.cleanup
def cleanup_then_raise(status):
real_cleanup(captured[0], status)
raise OSError("sentinel cleanup diagnostic")
with tempfile.TemporaryDirectory() as d:
try:
with patch.object(broker.Broker, "cleanup", side_effect=cleanup_then_raise):
with self.assertRaisesRegex(RuntimeError, "sentinel after handle creation") as raised:
self.make_bound_instance(Path(d), fail_stage="after-handle",
captured=captured, stages=stages, failure=failure)
instance = captured[0]
self.assertIs(failure[0], raised.exception)
self.assertIn("fixture cleanup failed: OSError", getattr(raised.exception, "__notes__", []))
self.assertTrue(probe_free(instance.set.issue.path))
finally:
if instance is not None:
real_cleanup(instance, "released")
def test_release_reports_socket_failure_separately_and_stops(self): def test_release_reports_socket_failure_separately_and_stops(self):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
@@ -326,12 +474,17 @@ class ManagedLockBrokerTests(unittest.TestCase):
target = root / "replacement-target"; target.write_text("untouched") target = root / "replacement-target"; target.write_text("untouched")
try: try:
data = json.loads(handle.read_text()) data = json.loads(handle.read_text())
with socket.socket(socket.AF_UNIX) as client: parent_fd, socket_name = broker._open_parent(sock_path)
client.settimeout(3); client.connect(str(sock_path)) try:
sock_path.unlink(); sock_path.symlink_to(target) with socket.socket(socket.AF_UNIX) as client:
client.sendall((json.dumps({"action": "release", "token": data["token"]}) + "\n").encode()) client.settimeout(3)
with client.makefile("rb") as stream: client.connect(f"/proc/self/fd/{parent_fd}/{socket_name}")
result = json.loads(stream.readline()) sock_path.unlink(); sock_path.symlink_to(target)
client.sendall((json.dumps({"action": "release", "token": data["token"]}) + "\n").encode())
with client.makefile("rb") as stream:
result = json.loads(stream.readline())
finally:
os.close(parent_fd)
self.assertEqual(result["error"], "cleanup-incomplete") self.assertEqual(result["error"], "cleanup-incomplete")
self.assertTrue(result["cleanup"]["lease_released"]) self.assertTrue(result["cleanup"]["lease_released"])
self.assertEqual(proc.wait(timeout=3), 2) self.assertEqual(proc.wait(timeout=3), 2)
@@ -580,6 +733,105 @@ class ManagedLockBrokerTests(unittest.TestCase):
self.assertNotEqual(p.returncode, 0) self.assertNotEqual(p.returncode, 0)
self.assertNotIn("token", p.stderr.lower()) self.assertNotIn("token", p.stderr.lower())
def test_fixture_binds_and_cleans_long_utf8_socket_path(self):
"""The in-process fixture must exercise the broker's FD-relative path."""
with tempfile.TemporaryDirectory() as d:
root = Path(d) / ("é" * 28)
root.mkdir()
instance = None
try:
instance = self.make_bound_instance(root)
self.assertGreater(len(os.fsencode(instance.sock_path)), 107)
self.assertTrue(instance.server)
self.assertIsNotNone(instance.socket_parent_fd)
finally:
if instance is not None:
instance.cleanup("released")
self.assertIsNone(instance.socket_parent_fd)
self.assertIsNone(instance.server)
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
self.assertFalse((runtime_dir(root, run_id="cleanup-test") / "broker.sock").exists())
def test_long_utf8_helper_request_preserves_unauthorized_semantics(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d) / ("é" * 28)
root.mkdir()
proc, handle, _ = self.start(root)
try:
self.assertEqual(self.request(handle, "status")["status"], "held")
self.assertEqual(
self.request(handle, "status", token=secrets.token_hex(32))["error"],
"unauthorized",
)
self.assertEqual(self.request(handle, "release")["status"], "released")
finally:
if proc.poll() is None:
proc.kill(); proc.wait(timeout=3)
proc.stdout.close(); proc.stderr.close()
def test_startup_failures_release_owned_resources_without_masking_original(self):
"""Each setup boundary must leave no owned FD, socket, lock, or token."""
import contextlib
import io
failures = ("metadata", "runtime-fd", "handle-write", "socket-bind")
for failure in failures:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as d:
root = Path(d)
ensure_cache(root)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); locks.chmod(0o700)
(locks / "repository.lock").touch(mode=0o600)
(locks / "issue-7.lock").touch(mode=0o600)
runtime = runtime_dir(root, run_id=f"failure-{failure}")
args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id=f"failure-{failure}",
handle=str(runtime / "broker.handle"),
socket=str(runtime / "broker.sock"), ttl_seconds=5.0,
acquire_timeout=0.0)
instance = broker.Broker(args)
original_write_all = broker._write_all
original_chmod = broker.os.chmod
calls = {"write": 0}
def partial_write(fd, data):
calls["write"] += 1
if calls["write"] == 1:
os.write(fd, data[:1])
raise OSError("injected handle write")
return original_write_all(fd, data)
def late_chmod(path, mode, **kwargs):
original_chmod(path, mode, **kwargs)
if path == "broker.sock":
raise OSError("injected socket setup")
with contextlib.ExitStack() as stack:
if failure == "metadata":
stack.enter_context(patch.object(instance.set.issue, "write_metadata",
side_effect=OSError("injected metadata")))
elif failure == "runtime-fd":
stack.enter_context(patch.object(broker.os, "dup",
side_effect=OSError("injected dup")))
elif failure == "handle-write":
stack.enter_context(patch.object(broker, "_write_all",
side_effect=partial_write))
else:
stack.enter_context(patch.object(broker.os, "chmod",
side_effect=late_chmod))
with contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(OSError):
instance.run()
self.assertTrue(probe_free(locks / "issue-7.lock"))
self.assertTrue(probe_free(locks / "repository.lock"))
self.assertIsNone(instance.socket_parent_fd)
self.assertIsNone(instance.server)
if failure in ("handle-write", "socket-bind"):
terminal = json.loads(instance.handle.read_text())
self.assertNotIn("token", terminal)
self.assertIn(terminal["status"], ("released", "cleanup-incomplete"))
self.assertFalse((runtime / "broker.sock").exists())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()