Files
gitea-agent/harness/tests/common/test_managed_lock_broker.py
T

958 lines
53 KiB
Python

import json
import os
import secrets
import stat
import subprocess
import sys
import tempfile
import time
import unittest
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
from harness.lib.managed_lock import ManagedLock, probe_free
from harness.lib import managed_lock_broker as broker
BROKER = Path(__file__).parents[2] / "lib" / "managed_lock_broker.py"
def wait_ready(proc, timeout=3):
line = proc.stdout.readline().decode()
if not line:
raise AssertionError(proc.stderr.read().decode())
return json.loads(line)
def ensure_cache(root, owner="alice", repo="ledger"):
repository = root / "repositories" / owner / repo
repository.mkdir(parents=True, exist_ok=True)
(root / "repositories" / owner).chmod(0o700)
repository.chmod(0o700)
return repository
def prepare_fixture_runtime(root, run_id):
path = runtime_dir(root, run_id=run_id)
path.mkdir(parents=True, exist_ok=True)
for directory in (path.parent.parent.parent, path.parent.parent, path.parent, path):
directory.chmod(0o700)
return path
def runtime_dir(root, owner="alice", repo="ledger", issue=7, run_id="run-1"):
return root / "repositories" / owner / repo / "worktrees" / f"issue-{issue}" / "runtime" / run_id
class ManagedLockBrokerTests(unittest.TestCase):
def start(self, root, owner="alice", repo="ledger", issue=7, ttl=5, role="gitea-impl-agent", mask=-1):
ensure_cache(root, owner, repo)
locks = root / "repositories" / owner / repo / "locks"
locks.mkdir(parents=True)
os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
(locks / f"issue-{issue}.lock").touch(mode=0o600)
runtime = runtime_dir(root, owner, repo, issue, "run-1")
handle = runtime / "broker.handle"
socket = runtime / "broker.sock"
cmd = [sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", owner,
"--repository", repo, "--issue", str(issue), "--role", role, "--run-id", "run-1",
"--handle", str(handle), "--socket", str(socket), "--ttl-seconds", str(ttl),
"--acquire-timeout", "0.3"]
proc = subprocess.Popen(cmd, cwd=Path.cwd(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, umask=mask)
ready = wait_ready(proc)
self.assertEqual(ready["status"], "held")
self.assertNotIn("token", json.dumps(ready))
return proc, handle, socket
def request(self, handle, action, ttl=None, token=None):
data = json.loads(handle.read_text())
if token is not None:
data["token"] = token
import socket
parent_fd, name = broker._open_parent(Path(data["socket"]))
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(3)
s.connect(f"/proc/self/fd/{parent_fd}/{name}")
payload = {"action": action, "token": data["token"] if token is None else token}
if ttl is not None:
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_operation_specific_broker_response_documentation(self):
import ast
harness = Path(__file__).parents[2]
operations = (harness / "operations/repository-cache.md").read_text()
reference = (harness / "agents/review/skills/gitea-code-review/references/native-review.md").read_text()
self.assertIn("### Operation-specific broker responses", operations)
contract = operations.split("### Operation-specific broker responses", 1)[1].split("### Canonical command templates", 1)[0]
for field in ("status", "deadline", "repository_held", "root", "owner", "repository", "issue", "role", "pid"):
self.assertIn(f"`{field}`", contract)
self.assertIn("request_handle(handle_path, action, ttl=None)", contract)
self.assertIn("### Broker response preflight before publication", reference)
section = reference.split("### Broker response preflight before publication", 1)[1].split("## Live integration", 1)[0]
recipe = section.split("```python\n", 1)[1].split("```", 1)[0]
ast.parse(recipe)
self.assertIn('broker.request_handle(broker_handle, "renew", ttl=ttl_seconds)', recipe)
self.assertIn('broker.request_handle(broker_handle, "status")', recipe)
self.assertIn('"error" in renewed', recipe)
self.assertIn('"error" in status', recipe)
self.assertIn('status.get("repository_held")', recipe)
self.assertNotIn('renewed["repository_held"]', recipe)
self.assertIn('lease.check(plan)', recipe)
def test_status_and_renew_have_distinct_success_and_error_shapes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, sock = self.start(root, ttl=30, role="gitea-review-agent")
status_keys = {"status", "deadline", "repository_held", "root", "owner", "repository", "issue", "role", "pid"}
try:
for held in (False, True):
with self.subTest(repository_held=held):
if held:
self.assertEqual(broker.request_handle(handle, "acquire_repository"), {"status": "repository-held"})
renewed = broker.request_handle(handle, "renew", ttl=30)
self.assertEqual(set(renewed), {"status", "deadline"})
self.assertEqual(renewed["status"], "repository-held" if held else "held")
self.assertIs(type(renewed["deadline"]), float)
self.assertGreater(renewed["deadline"], time.monotonic())
with self.assertRaises(KeyError):
renewed["repository_held"]
status = broker.request_handle(handle, "status")
self.assertEqual(set(status), status_keys)
self.assertIs(status["repository_held"], held)
self.assertEqual(status["status"], renewed["status"])
self.assertEqual(status["deadline"], renewed["deadline"])
self.assertEqual({key: status[key] for key in ("root", "owner", "repository", "issue", "role", "pid")},
{"root": str(root), "owner": "alice", "repository": "ledger", "issue": 7,
"role": "gitea-review-agent", "pid": proc.pid})
rejected = broker.request_handle(handle, "renew", ttl=0)
self.assertEqual(rejected, {"error": "invalid ttl_seconds"})
unchanged = broker.request_handle(handle, "status")
self.assertEqual(unchanged, status)
finally:
try:
if proc.poll() is None:
self.assertEqual(broker.request_handle(handle, "release"), {"status": "released"})
self.assertEqual(proc.wait(timeout=3), 0)
finally:
if proc.poll() is None:
proc.kill(); proc.wait(timeout=3)
proc.stdout.close(); proc.stderr.close()
self.assertFalse(sock.exists())
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
def test_serve_parser_accepts_only_canonical_roles(self):
import contextlib
import io
roles = {"gitea-spec-agent", "gitea-impl-agent", "gitea-review-agent"}
self.assertEqual(broker.ROLES, roles)
with tempfile.TemporaryDirectory() as d:
root = Path(d) / "not-created"
args = ["serve", "--root", str(root), "--owner", "alice", "--repository", "ledger",
"--issue", "7", "--run-id", "role-probe", "--ttl-seconds", "30"]
for role in sorted(roles):
with self.subTest(role=role):
self.assertEqual(broker.parser().parse_args([*args, "--role", role]).role, role)
for role in ("spec", "impl", "review", "gitea-release-agent", "Gitea-spec-agent", " gitea-spec-agent", "gitea-spec-agent "):
with self.subTest(role=role):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as rejected:
broker.parser().parse_args([*args, "--role", role])
self.assertEqual(rejected.exception.code, 2)
self.assertIn("invalid choice", stderr.getvalue())
for allowed in roles:
self.assertIn(allowed, stderr.getvalue())
help_text = io.StringIO()
with contextlib.redirect_stdout(help_text), self.assertRaises(SystemExit) as helped:
broker.parser().parse_args(["serve", "--help"])
self.assertEqual(helped.exception.code, 0)
for role in roles:
self.assertIn(role, help_text.getvalue())
self.assertFalse(root.exists())
# The direct Python constructor retains its independent role guard.
with self.assertRaisesRegex(ValueError, "invalid role"):
broker.Broker(Namespace(role="spec"))
def test_invalid_serve_role_cli_reports_choices_before_runtime_creation(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d) / "not-created"
result = subprocess.run(
[sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", "alice",
"--repository", "ledger", "--issue", "7", "--role", "spec", "--run-id", "role-probe",
"--ttl-seconds", "30"], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, "")
self.assertFalse(root.exists())
self.assertIn("invalid choice", result.stderr)
for role in ("gitea-spec-agent", "gitea-impl-agent", "gitea-review-agent"):
self.assertIn(role, result.stderr)
self.assertNotIn("error: ValueError", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_canonical_role_values_and_example_are_documented(self):
doc = (Path(__file__).parents[2] / "operations/repository-cache.md").read_text()
section = doc.split("### Canonical command templates", 1)[1].split("## Lock 규칙", 1)[0]
for role in ("gitea-spec-agent", "gitea-impl-agent", "gitea-review-agent"):
self.assertIn(f"`{role}`", section)
self.assertIn("--role gitea-spec-agent --run-id", section)
self.assertIn("spec`/`impl`/`review", section)
self.assertIn("authorization", section)
def test_constructor_rejects_lexical_root_and_handle_aliases(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); ensure_cache(root)
locks = root / 'repositories/alice/ledger/locks'; locks.mkdir(mode=0o700)
values = dict(root=str(root), owner='alice', repository='ledger', issue=7,
role='gitea-impl-agent', run_id='aliases', ttl_seconds=3,
acquire_timeout=0, handle=None, socket=None)
path = runtime_dir(root, run_id='aliases')
for override in [dict(root=f'{root}/./'), dict(handle=f'{path}/./broker.handle')]:
with self.subTest(override=override), self.assertRaises(ValueError):
broker.Broker(Namespace(**(values | override)))
self.assertFalse(path.exists())
def test_ready_retains_only_one_pinned_runtime_directory_descriptor(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, socket = self.start(root)
try:
targets = []
for fd in Path(f'/proc/{proc.pid}/fd').iterdir():
try:
if os.readlink(fd) == str(handle.parent):
targets.append(fd.name)
except FileNotFoundError:
pass
self.assertEqual(len(targets), 1, f'leaked runtime descriptors: {targets}')
finally:
broker.request_handle(handle, 'release')
proc.wait(timeout=3)
proc.stdout.close(); proc.stderr.close()
def test_serve_defaults_to_canonical_names_and_rejects_path_override(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
ensure_cache(root)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(); locks.chmod(0o700)
(locks / "repository.lock").touch(mode=0o600)
(locks / "issue-7.lock").touch(mode=0o600)
base = [sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", "alice",
"--repository", "ledger", "--issue", "7", "--role", "gitea-impl-agent",
"--run-id", "defaults", "--ttl-seconds", "3"]
proc = subprocess.Popen(base, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
ready = wait_ready(proc)
expected = runtime_dir(root, run_id="defaults")
self.assertEqual(Path(ready["handle"]), expected / "broker.handle")
self.assertEqual(Path(ready["socket"]), expected / "broker.sock")
broker.request_handle(expected / "broker.handle", "release")
self.assertEqual(proc.wait(timeout=2), 0)
finally:
if proc.poll() is None: proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
runtime = runtime_dir(root, run_id="override")
result = subprocess.run([*base[:], "--handle", str(root / "elsewhere"), "--run-id", "override"],
capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(runtime.exists())
def test_issue_contention_does_not_create_runtime(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
ensure_cache(root)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(); locks.chmod(0o700)
(locks / "repository.lock").touch(mode=0o600)
issue_lock = locks / "issue-7.lock"
issue_lock.touch(mode=0o600)
with ManagedLock(issue_lock):
proc = subprocess.run([sys.executable, str(BROKER), "serve", "--root", str(root),
"--owner", "alice", "--repository", "ledger", "--issue", "7",
"--role", "gitea-impl-agent", "--run-id", "blocked", "--ttl-seconds", "2"],
capture_output=True, text=True)
self.assertNotEqual(proc.returncode, 0)
self.assertFalse(runtime_dir(root, run_id="blocked").exists())
def test_long_socket_path_connects_via_held_directory_fd(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d) / ("r" * 48)
root.mkdir()
ensure_cache(root)
proc, handle, sock = self.start(root)
try:
self.assertGreater(len(str(sock).encode()), 107)
self.assertEqual(broker.request_handle(handle, "status")["status"], "held")
self.assertEqual(broker.request_handle(handle, "release")["status"], "released")
self.assertEqual(proc.wait(timeout=3), 0)
finally:
if proc.poll() is None: proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
def test_cross_process_contention_and_authenticated_release(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, _ = self.start(root)
try:
self.assertFalse(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
self.assertEqual(self.request(handle, "release")["status"], "released")
proc.wait(timeout=2)
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
self.assertNotIn("token", json.loads(handle.read_text()))
finally:
if proc.poll() is None:
proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
def test_probe_cli_fails_while_held_and_passes_after_release(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, _ = self.start(root)
command = [sys.executable, str(BROKER), "probe", "--root", str(root),
"--owner", "alice", "--repository", "ledger", "--issue", "7"]
try:
held = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(held.returncode, 1)
self.assertFalse(json.loads(held.stdout)["free"])
self.assertEqual(self.request(handle, "release")["status"], "released")
proc.wait(timeout=2)
free = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(free.returncode, 0)
self.assertTrue(json.loads(free.stdout)["free"])
finally:
if proc.poll() is None:
proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
def test_issue_probe_passes_when_another_workflow_holds_repository(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, _ = self.start(root)
base = [sys.executable, str(BROKER), "probe", "--root", str(root),
"--owner", "alice", "--repository", "ledger", "--issue", "7"]
repository = root / "repositories/alice/ledger/locks/repository.lock"
try:
self.assertEqual(self.request(handle, "release")["status"], "released")
proc.wait(timeout=2)
with ManagedLock(repository):
issue = subprocess.run([*base, "--resource", "issue"], capture_output=True, text=True)
all_resources = subprocess.run([*base, "--resource", "all"], capture_output=True, text=True)
self.assertEqual(issue.returncode, 0)
self.assertTrue(json.loads(issue.stdout)["issue_free"])
self.assertEqual(all_resources.returncode, 1)
self.assertFalse(json.loads(all_resources.stdout)["repository_free"])
finally:
if proc.poll() is None:
proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
def test_new_handle_mode_is_exact_under_restrictive_umask(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "handle.json"
old = os.umask(0o777)
try:
broker._write_handle(path, {"token": "a" * 64, "socket": "/tmp/x"}, exclusive=True)
finally:
os.umask(old)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
def test_socket_mode_and_cleanup_under_umasks(self):
for mask in (0o022, 0o077, 0o777):
with self.subTest(mask=mask), tempfile.TemporaryDirectory() as d:
proc, handle, sock = self.start(Path(d), mask=mask)
try:
self.assertEqual(stat.S_IMODE(sock.lstat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(handle.parent.stat().st_mode), 0o700)
self.assertTrue(stat.S_ISSOCK(sock.lstat().st_mode))
self.assertEqual(broker.request_handle(handle, "status")["status"], "held")
self.assertEqual(broker.request_handle(handle, "release")["status"], "released")
self.assertEqual(proc.wait(timeout=3), 0)
self.assertFalse(sock.exists())
self.assertTrue(probe_free(Path(d) / "repositories/alice/ledger/locks/issue-7.lock"))
finally:
if proc.poll() is None:
proc.terminate(); proc.wait(timeout=3)
proc.stdout.close(); proc.stderr.close()
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
ensure_cache(root)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); locks.chmod(0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = prepare_fixture_runtime(root, "cleanup-test")
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"),
socket=str(runtime / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0))
if captured is not None:
captured.append(instance)
observed = stages if stages is not None else {}
try:
instance.set.issue.acquire()
observed["issue_acquired"] = instance.set.issue.held
if fail_stage == "after-issue":
raise RuntimeError("sentinel after issue acquire")
broker._write_handle(instance.handle, instance.state, exclusive=True,
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):
with tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d))
with patch.object(broker.os, "unlink", side_effect=PermissionError("injected")):
result = instance.handle_request({"action": "release", "token": instance.token})
self.assertEqual(result["error"], "cleanup-incomplete")
self.assertTrue(result["cleanup"]["lease_released"])
self.assertFalse(result["cleanup"]["socket_removed"])
self.assertTrue(instance.stopping)
self.assertTrue(probe_free(instance.set.issue.path))
self.assertNotIn("token", json.loads(instance.handle.read_text()))
self.assertIsNone(instance.socket_parent_fd)
self.assertIsNone(instance.server)
def test_cleanup_preserves_replaced_path_and_reports_incomplete(self):
for kind in ("file", "symlink"):
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d))
instance.sock_path.unlink()
target = Path(d) / "target"; target.write_text("foreign")
if kind == "file": instance.sock_path.write_text("replacement")
else: instance.sock_path.symlink_to(target)
self.assertFalse(instance.cleanup("released"))
self.assertTrue(instance.sock_path.is_symlink() if kind == "symlink" else instance.sock_path.is_file())
self.assertEqual(target.read_text(), "foreign")
self.assertTrue(instance.cleanup_result["lease_released"])
self.assertIn("socket-identity-changed", instance.cleanup_result["errors"])
self.assertFalse(instance.cleanup("released")) # Do not erase a partial result.
def test_socket_removed_after_expiry_and_sigterm(self):
for action in ("expiry", "sigterm"):
with self.subTest(action=action), tempfile.TemporaryDirectory() as d:
proc, handle, sock = self.start(Path(d), ttl=1 if action == "expiry" else 5)
try:
if action == "sigterm": proc.terminate()
self.assertEqual(proc.wait(timeout=4), 0)
self.assertFalse(sock.exists())
self.assertTrue(probe_free(Path(d) / "repositories/alice/ledger/locks/issue-7.lock"))
self.assertNotIn("token", json.loads(handle.read_text()))
finally:
if proc.poll() is None: proc.kill(); proc.wait(timeout=3)
proc.stdout.close(); proc.stderr.close()
def test_partial_cleanup_response_delivered_before_nonzero_exit(self):
import socket
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, sock_path = self.start(root)
target = root / "replacement-target"; target.write_text("untouched")
try:
data = json.loads(handle.read_text())
parent_fd, socket_name = broker._open_parent(sock_path)
try:
with socket.socket(socket.AF_UNIX) as client:
client.settimeout(3)
client.connect(f"/proc/self/fd/{parent_fd}/{socket_name}")
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.assertTrue(result["cleanup"]["lease_released"])
self.assertEqual(proc.wait(timeout=3), 2)
self.assertTrue(sock_path.is_symlink())
self.assertEqual(target.read_text(), "untouched")
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
terminal = json.loads(handle.read_text())
self.assertNotIn("token", terminal)
self.assertFalse(terminal["cleanup"]["socket_removed"])
self.assertNotIn(data["token"], proc.stderr.read().decode())
finally:
if proc.poll() is None: proc.kill(); proc.wait(timeout=3)
proc.stdout.close(); proc.stderr.close()
def test_socket_permission_failure_has_no_ready_and_cleans_owned_socket(self):
import contextlib
import io
with tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d))
instance.cleanup("released")
fresh = broker.Broker(Namespace(root=str(instance.root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="startup-permission", handle=str(runtime_dir(instance.root, run_id="startup-permission") / "broker.handle"),
socket=str(runtime_dir(instance.root, run_id="startup-permission") / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0))
out = io.StringIO()
real_chmod = os.chmod
def fail_socket_chmod(path, mode, **kwargs):
if path == fresh.sock_path.name:
raise PermissionError("injected")
return real_chmod(path, mode, **kwargs)
with patch.object(broker.os, "chmod", side_effect=fail_socket_chmod), contextlib.redirect_stdout(out):
with self.assertRaises(PermissionError): fresh.run()
self.assertEqual(out.getvalue(), "")
self.assertFalse(fresh.sock_path.exists())
self.assertTrue(probe_free(fresh.set.issue.path))
self.assertNotIn("token", json.loads(fresh.handle.read_text()))
def test_cleanup_respects_lock_order_after_repository_error(self):
with tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d))
instance.set.repository.acquire()
with patch.object(instance.set.repository, "release", side_effect=OSError("injected")):
self.assertFalse(instance.cleanup("released"))
self.assertFalse(probe_free(instance.set.issue.path)) # Repository still held; preserve lock order.
self.assertFalse(instance.cleanup_result["lease_released"])
self.assertTrue(instance.cleanup_result["socket_removed"])
self.assertIn("repository-release", instance.cleanup_result["errors"])
instance.cleanup("released") # Retry held FD, but keep previous failure visible.
self.assertTrue(probe_free(instance.set.repository.path))
self.assertNotIn("token", json.loads(instance.handle.read_text()))
def test_startup_handle_replacement_is_preserved(self):
import contextlib
import io
with tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d)); instance.cleanup("released")
fresh = broker.Broker(Namespace(root=str(instance.root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="handle-swap", handle=str(runtime_dir(instance.root, run_id="handle-swap") / "broker.handle"),
socket=str(runtime_dir(instance.root, run_id="handle-swap") / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0))
original_write = broker._write_handle
foreign = b'{"foreign":"preserve"}\n'
def replace_after_create(path, state, **kwargs):
original_write(path, state, **kwargs)
path.rename(path.with_suffix(".original")) # Keep original inode alive.
path.write_bytes(foreign); path.chmod(0o600)
with patch.object(broker, "_write_handle", side_effect=replace_after_create), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises((OSError, ValueError)): fresh.run()
self.assertEqual(fresh.handle.read_bytes(), foreign)
self.assertTrue(probe_free(fresh.set.issue.path))
self.assertIn("handle-save", fresh.cleanup_result["errors"])
def test_partial_cleanup_durable_status_is_incomplete(self):
with tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d))
with patch.object(broker.os, "unlink", side_effect=PermissionError("injected")):
instance.cleanup("released")
state = json.loads(instance.handle.read_text())
self.assertEqual(state["status"], "cleanup-incomplete")
self.assertTrue(state["cleanup"]["lease_released"])
self.assertFalse(state["cleanup"]["socket_removed"])
self.assertNotIn("token", state)
def test_socket_replacement_during_permission_setup_is_preserved(self):
import contextlib
import io
with tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d)); instance.cleanup("released")
fresh = broker.Broker(Namespace(root=str(instance.root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="socket-swap", handle=str(runtime_dir(instance.root, run_id="socket-swap") / "broker.handle"),
socket=str(runtime_dir(instance.root, run_id="socket-swap") / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0))
real_chmod = os.chmod
def replace_after_chmod(path, mode, **kwargs):
real_chmod(path, mode, **kwargs)
if path == fresh.sock_path.name:
fresh.sock_path.unlink()
fresh.sock_path.write_text("foreign socket replacement")
out = io.StringIO()
with patch.object(broker.os, "chmod", side_effect=replace_after_chmod), contextlib.redirect_stdout(out):
with self.assertRaises(ValueError): fresh.run()
self.assertEqual(out.getvalue(), "")
self.assertEqual(fresh.sock_path.read_text(), "foreign socket replacement")
self.assertTrue(probe_free(fresh.set.issue.path))
self.assertEqual(json.loads(fresh.handle.read_text())["status"], "cleanup-incomplete")
def test_persist_rejects_replaced_handle_after_ready(self):
with tempfile.TemporaryDirectory() as d:
instance = self.make_bound_instance(Path(d))
instance.handle.rename(instance.handle.with_suffix(".original"))
instance.handle.write_text("foreign"); instance.handle.chmod(0o600)
with self.assertRaises(ValueError): instance.persist("held")
self.assertFalse(instance.cleanup("released"))
self.assertEqual(instance.handle.read_text(), "foreign")
self.assertTrue(probe_free(instance.set.issue.path))
def test_repository_scope_release_keeps_issue(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root)
try:
self.assertEqual(self.request(handle, "acquire_repository")["status"], "repository-held")
self.assertFalse(probe_free(root / "repositories/alice/ledger/locks/repository.lock"))
self.assertEqual(self.request(handle, "release_repository")["status"], "held")
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/repository.lock"))
self.assertFalse(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
finally:
self.request(handle, "release"); proc.wait(timeout=2); proc.stdout.close(); proc.stderr.close()
def test_ttl_expiry_updates_handle_and_releases(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root, ttl=1)
proc.wait(timeout=4)
state = json.loads(handle.read_text())
self.assertEqual(state["status"], "expired")
self.assertNotIn("token", state)
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
proc.stdout.close(); proc.stderr.close()
def test_expired_lease_rejects_renew_and_release_before_action(self):
for action in ("renew", "release"):
with self.subTest(action=action), tempfile.TemporaryDirectory() as d:
root = Path(d)
ensure_cache(root)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = prepare_fixture_runtime(root, "expiry-test")
args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="expiry-test",
handle=str(runtime / "broker.handle"), socket=str(runtime / "broker.sock"),
ttl_seconds=5.0, acquire_timeout=0.0)
instance = broker.Broker(args)
instance.set.issue.acquire()
broker._write_handle(instance.handle, instance.state, exclusive=True)
instance.handle_created = True; instance.started = True
instance.deadline = time.monotonic() - 1
request: dict[str, object] = {"action": action, "token": instance.token}
if action == "renew": request["ttl_seconds"] = 30
self.assertEqual(instance.handle_request(request), {"error": "expired"})
self.assertTrue(probe_free(locks / "issue-7.lock"))
terminal = json.loads(instance.handle.read_text())
self.assertEqual(terminal["status"], "expired")
self.assertNotIn("token", terminal)
def test_startup_failure_releases_issue_and_terminalizes_partial_handle(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
ensure_cache(root)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = runtime_dir(root, run_id="startup-failure")
handle = runtime / "broker.handle"
args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="startup-failure",
handle=str(handle), socket=str(runtime / "broker.sock"),
ttl_seconds=5.0, acquire_timeout=0.0)
instance = broker.Broker(args)
real_write = broker._write_handle
def partial_then_fail(path, state, *, exclusive=False, on_created=None):
real_write(path, state, exclusive=exclusive, on_created=on_created)
raise OSError("synthetic startup failure")
with patch.object(broker, "_write_handle", side_effect=partial_then_fail):
with self.assertRaises(OSError):
instance.run()
self.assertTrue(probe_free(locks / "issue-7.lock"))
terminal = json.loads(handle.read_text())
self.assertIn(terminal["status"], ("released", "expired"))
self.assertNotIn("token", terminal)
def test_startup_existing_handle_is_never_claimed_or_modified(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
ensure_cache(root)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = prepare_fixture_runtime(root, "existing-handle")
handle = runtime / "broker.handle"
original = b'{"foreign":"do-not-touch"}\n'
handle.write_bytes(original); handle.chmod(0o600)
args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="existing-handle",
handle=str(handle), socket=str(runtime / "broker.sock"),
ttl_seconds=5.0, acquire_timeout=0.0)
instance = broker.Broker(args)
with self.assertRaises(ValueError):
instance.run()
self.assertEqual(handle.read_bytes(), original)
self.assertTrue(probe_free(locks / "issue-7.lock"))
def test_cli_client_compatibility_and_nonprinting_function(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root)
try:
result = broker.request_handle(handle, "status")
self.assertEqual(result["status"], "held")
self.assertEqual(result["root"], str(root))
self.assertEqual(broker.read_handle_metadata(handle)["root"], str(root))
self.assertNotIn("token", json.dumps(result))
cli = subprocess.run([sys.executable, str(BROKER), "request", "--handle", str(handle), "--action", "status"],
capture_output=True, text=True)
self.assertEqual(cli.returncode, 0)
self.assertEqual(json.loads(cli.stdout)["status"], "held")
finally:
broker.request_handle(handle, "release"); proc.wait(timeout=2); proc.stdout.close(); proc.stderr.close()
def test_token_not_in_ready_or_errors_and_bad_handle_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root)
try:
raw = handle.read_text()
self.assertIn("token", raw)
bad = root / "bad"; bad.symlink_to(handle)
result = subprocess.run([sys.executable, str(BROKER), "request", "--handle", str(bad), "--action", "status"], capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn(json.loads(raw)["token"], result.stderr)
self.assertEqual(self.request(handle, "status", token=secrets.token_hex(32))["error"], "unauthorized")
finally:
self.request(handle, "release"); proc.wait(timeout=2); proc.stdout.close(); proc.stderr.close()
def test_unsafe_handle_and_identifiers_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
for role in ("bad", "gitea-impl-agent/evil"):
p = subprocess.run([sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", "a", "--repository", "r", "--issue", "1", "--role", role, "--run-id", "x", "--handle", str(root/"h"), "--socket", str(root/"s"), "--ttl-seconds", "1"], capture_output=True, text=True)
self.assertNotEqual(p.returncode, 0)
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__":
unittest.main()