333 lines
12 KiB
Python
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()
|