958 lines
53 KiB
Python
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()
|