Files
Gitea-Tools/tests/test_issue_872_dead_session_two_base_syncs.py
T

333 lines
12 KiB
Python

"""Dead-session recovery for cross-session PR base-syncs (#872).
Validates that when a sanctioned server-side base-sync (``gitea_update_pr_branch_by_merge``)
advances a PR's remote head from A to B (a merge commit whose first parent is A),
a subsequent author session whose local worktree is at A can recover the dead-session
lock via HEAD_RELATION_REMOTE_MERGE_SYNCED and execute a second base-sync (B to C)
without deadlock or RuntimeError.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_recovery # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_worktree # noqa: E402
ISSUE = 8720
PR_NUMBER = 8721
BRANCH = f"fix/issue-{ISSUE}-dead-session-two-base-syncs"
IDENTITY = "example-author"
PROFILE = "prgs-author"
REMOTE = "prgs"
ORG = "ExampleOrg"
REPO = "ExampleRepo"
def dead_pid() -> int:
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
return proc.pid
def future_ts(hours: int = 4) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def _git(cwd, *args):
return subprocess.run(
["git", "-C", cwd, *args],
capture_output=True,
text=True,
check=True,
)
def _rev(cwd, ref="HEAD") -> str:
return _git(cwd, "rev-parse", ref).stdout.strip()
def build_two_base_sync_repo(tmp: str) -> dict:
"""Build a repo simulating two sequential base-sync merges."""
_git(tmp, "init", "-q", "-b", "master")
_git(tmp, "config", "user.email", "[email protected]")
_git(tmp, "config", "user.name", "Author")
Path(tmp, "base.txt").write_text("base 1\n")
_git(tmp, "add", "-A")
_git(tmp, "commit", "-q", "-m", "initial master")
# Feature branch cut from initial master -> Head A (prior/recorded head)
_git(tmp, "checkout", "-q", "-b", BRANCH)
Path(tmp, "feature.txt").write_text("feature work\n")
_git(tmp, "add", "-A")
_git(tmp, "commit", "-q", "-m", "feature commit A")
head_a = _rev(tmp)
# Master advances -> Master 1
_git(tmp, "checkout", "-q", "master")
Path(tmp, "base.txt").write_text("base 1\nbase 2\n")
_git(tmp, "add", "-A")
_git(tmp, "commit", "-q", "-m", "master advance 1")
master_1 = _rev(tmp)
# First sync: merge master into feature -> Head B (merge commit, first parent = A)
_git(tmp, "checkout", "-q", BRANCH)
_git(tmp, "merge", "-q", "--no-ff", "-m", "First base-sync (merge master)", "master")
head_b = _rev(tmp)
# Master advances again -> Master 2
_git(tmp, "checkout", "-q", "master")
Path(tmp, "base.txt").write_text("base 1\nbase 2\nbase 3\n")
_git(tmp, "add", "-A")
_git(tmp, "commit", "-q", "-m", "master advance 2")
master_2 = _rev(tmp)
# Second sync: merge master into feature -> Head C (merge commit, first parent = B)
_git(tmp, "checkout", "-q", BRANCH)
_git(tmp, "merge", "-q", "--no-ff", "-m", "Second base-sync (merge master)", "master")
head_c = _rev(tmp)
# Non-merge rebase/force-pushed branch shape
_git(tmp, "checkout", "-q", "-b", "rebased-branch", head_a)
Path(tmp, "rebase.txt").write_text("rebased\n")
_git(tmp, "add", "-A")
_git(tmp, "commit", "-q", "-m", "rebased commit")
rebased_head = _rev(tmp)
# Reset worktree back to head A, as a dead session leaving local worktree at A
_git(tmp, "checkout", "-q", BRANCH)
_git(tmp, "reset", "-q", "--hard", head_a)
return {
"head_a": head_a,
"master_1": master_1,
"head_b": head_b,
"master_2": master_2,
"head_c": head_c,
"rebased_head": rebased_head,
}
def make_dead_lock(worktree, **over):
pid = dead_pid()
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": worktree,
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"session_pid": pid,
"pid": pid,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"branch": BRANCH,
"worktree_path": worktree,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"expires_at": future_ts(),
},
}
lock.update(over)
return lock
class TestDeadSessionTwoBaseSyncs(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.shas = build_two_base_sync_repo(self.tmp)
self.head_a = self.shas["head_a"]
self.head_b = self.shas["head_b"]
self.head_c = self.shas["head_c"]
def test_first_base_sync_dead_session_recovery(self):
"""AC1: dead session after first base sync (remote=B, local=A) recovers via merge-sync."""
lock = make_dead_lock(self.tmp, synced_pr_head=self.head_b)
obs = issue_lock_worktree.read_merge_sync_provenance(
self.tmp,
prior_head_sha=self.head_a,
synced_head_sha=self.head_b,
remote=REMOTE,
)
self.assertTrue(obs["is_merge_sync"], obs["reasons"])
self.assertTrue(obs["prior_is_ancestor"])
self.assertTrue(obs["synced_is_merge"])
self.assertTrue(obs["first_parent_reaches_prior"])
res = issue_lock_recovery.assess_dead_session_lock_recovery(
lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.tmp,
remote=REMOTE,
org=ORG,
repo=REPO,
identity=IDENTITY,
profile=PROFILE,
current_branch=BRANCH,
porcelain_status="",
head_sha=self.head_a,
remote_head_sha=self.head_b,
pr_head_sha=self.head_b,
pr_number=PR_NUMBER,
competing_live_locks=[],
candidate_branches=[BRANCH],
current_pid=os.getpid(),
remote_branch_exists=True,
sync_provenance=obs,
)
self.assertEqual(res["outcome"], issue_lock_recovery.RECOVERY_SANCTIONED, res["reasons"])
self.assertEqual(
res["evidence"]["head_relation"],
issue_lock_recovery.HEAD_RELATION_REMOTE_MERGE_SYNCED,
)
self.assertEqual(res["evidence"]["accepted_head"], self.head_b)
def test_second_base_sync_head_refresh_after_recovery(self):
"""AC2: after recovery, second base sync (remote B -> C) updates durable lock head."""
lock_dir = tempfile.mkdtemp()
lock_data = make_dead_lock(self.tmp, synced_pr_head=self.head_b)
# Rebind to current PID as gitea_lock_issue does upon sanctioned recovery
lock_data["session_pid"] = os.getpid()
lock_data["pid"] = os.getpid()
issue_lock_store.save_lock_file(
issue_lock_store.lock_file_path(
remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, lock_dir=lock_dir
),
lock_data,
)
refresh_res = issue_lock_store.apply_durable_lock_head_refresh(
remote=REMOTE,
org=ORG,
repo=REPO,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.tmp,
pr_number=PR_NUMBER,
identity=IDENTITY,
profile=PROFILE,
current_pid=os.getpid(),
expected_old_head=self.head_b,
new_head=self.head_c,
synced_at=future_ts(0),
base_head=self.shas["master_2"],
lock_dir=lock_dir,
)
self.assertTrue(refresh_res["refreshed"], refresh_res["reasons"])
self.assertTrue(refresh_res["read_after_write_ok"])
loaded = issue_lock_store.load_issue_lock(
remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, lock_dir=lock_dir
)
self.assertEqual(loaded.get("synced_pr_head"), self.head_c)
def test_dirty_worktree_blocks_recovery(self):
"""AC3: tracked dirty edits block dead-session recovery."""
lock = make_dead_lock(self.tmp)
obs = issue_lock_worktree.read_merge_sync_provenance(
self.tmp, prior_head_sha=self.head_a, synced_head_sha=self.head_b
)
res = issue_lock_recovery.assess_dead_session_lock_recovery(
lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.tmp,
remote=REMOTE,
org=ORG,
repo=REPO,
identity=IDENTITY,
profile=PROFILE,
current_branch=BRANCH,
porcelain_status=" M feature.txt\n",
head_sha=self.head_a,
remote_head_sha=self.head_b,
pr_head_sha=self.head_b,
pr_number=PR_NUMBER,
competing_live_locks=[],
candidate_branches=[BRANCH],
current_pid=os.getpid(),
remote_branch_exists=True,
sync_provenance=obs,
)
self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED)
self.assertTrue(any("dirty" in r or "tracked" in r for r in res["reasons"]))
def test_foreign_reclaimer_blocks_recovery(self):
"""AC4: a foreign claimant cannot recover a dead-session lock."""
lock = make_dead_lock(self.tmp)
obs = issue_lock_worktree.read_merge_sync_provenance(
self.tmp, prior_head_sha=self.head_a, synced_head_sha=self.head_b
)
res = issue_lock_recovery.assess_dead_session_lock_recovery(
lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.tmp,
remote=REMOTE,
org=ORG,
repo=REPO,
identity="intruder-user",
profile=PROFILE,
current_branch=BRANCH,
porcelain_status="",
head_sha=self.head_a,
remote_head_sha=self.head_b,
pr_head_sha=self.head_b,
pr_number=PR_NUMBER,
competing_live_locks=[],
candidate_branches=[BRANCH],
current_pid=os.getpid(),
remote_branch_exists=True,
sync_provenance=obs,
)
self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED)
def test_non_merge_rebased_remote_head_blocks_recovery(self):
"""AC5: a rebased/force-pushed remote head (not a merge commit) is refused."""
lock = make_dead_lock(self.tmp)
obs = issue_lock_worktree.read_merge_sync_provenance(
self.tmp, prior_head_sha=self.head_a, synced_head_sha=self.shas["rebased_head"]
)
self.assertFalse(obs["is_merge_sync"])
res = issue_lock_recovery.assess_dead_session_lock_recovery(
lock,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.tmp,
remote=REMOTE,
org=ORG,
repo=REPO,
identity=IDENTITY,
profile=PROFILE,
current_branch=BRANCH,
porcelain_status="",
head_sha=self.head_a,
remote_head_sha=self.shas["rebased_head"],
pr_head_sha=self.shas["rebased_head"],
pr_number=PR_NUMBER,
competing_live_locks=[],
candidate_branches=[BRANCH],
current_pid=os.getpid(),
remote_branch_exists=True,
sync_provenance=obs,
)
self.assertEqual(res["outcome"], issue_lock_recovery.REFUSED)
if __name__ == "__main__":
unittest.main()