Close canonical cleanup race windows
This commit is contained in:
parent
1a6f33e2de
commit
d2bb95fe61
4 changed files with 805 additions and 125 deletions
|
|
@ -293,19 +293,24 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
proposal_path = root / ".docforge/changesets/update-race.json"
|
||||
proposal_bytes = proposal_path.read_bytes()
|
||||
target = root / "docs/content/workflow.md"
|
||||
exchange = application_module.rename_exchange_at
|
||||
exchange = application_module.rename_exchange_between_at
|
||||
raced = False
|
||||
|
||||
def race(directory_fd: int, first: str, second: str) -> None:
|
||||
def race(
|
||||
first_directory_fd: int,
|
||||
first: str,
|
||||
second_directory_fd: int,
|
||||
second: str,
|
||||
) -> None:
|
||||
nonlocal raced
|
||||
if second == target.name and not raced:
|
||||
raced = True
|
||||
target.write_bytes(target.read_bytes() + b"\nExternal edit at exchange.\n")
|
||||
exchange(directory_fd, first, second)
|
||||
exchange(first_directory_fd, first, second_directory_fd, second)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"docforge.application.rename_exchange_at",
|
||||
"docforge.application.rename_exchange_between_at",
|
||||
side_effect=race,
|
||||
),
|
||||
self.assertRaises(DocForgeError) as captured,
|
||||
|
|
@ -403,21 +408,26 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
],
|
||||
)
|
||||
delete_target = delete_root / "docs/content/proof.toml"
|
||||
exchange = application_module.rename_exchange_at
|
||||
exchange = application_module.rename_exchange_between_at
|
||||
deleted_race = False
|
||||
|
||||
def race_delete(directory_fd: int, first: str, second: str) -> None:
|
||||
def race_delete(
|
||||
first_directory_fd: int,
|
||||
first: str,
|
||||
second_directory_fd: int,
|
||||
second: str,
|
||||
) -> None:
|
||||
nonlocal deleted_race
|
||||
if second == delete_target.name and not deleted_race:
|
||||
deleted_race = True
|
||||
delete_target.write_bytes(
|
||||
delete_target.read_bytes() + b"\n# foreign delete edit\n"
|
||||
)
|
||||
exchange(directory_fd, first, second)
|
||||
exchange(first_directory_fd, first, second_directory_fd, second)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"docforge.application.rename_exchange_at",
|
||||
"docforge.application.rename_exchange_between_at",
|
||||
side_effect=race_delete,
|
||||
),
|
||||
self.assertRaises(DocForgeError) as delete_error,
|
||||
|
|
@ -432,6 +442,167 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
self.assertIn("# foreign delete edit", delete_target.read_text(encoding="utf-8"))
|
||||
self.assertFalse(tuple(delete_target.parent.glob(".docforge-apply-*")))
|
||||
|
||||
def test_delete_detach_race_restores_foreign_target_and_retains_original(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
proposal = store.register(
|
||||
"delete-detach-race",
|
||||
[
|
||||
{
|
||||
"operation": "delete",
|
||||
"node_id": "proof.validation",
|
||||
"relationship_changes": [
|
||||
{
|
||||
"action": "remove",
|
||||
"source_id": "proof.validation",
|
||||
"relation": "proves",
|
||||
"target_id": "guide.workflow",
|
||||
}
|
||||
],
|
||||
"rationale": "Race the final no-replace canonical detachment.",
|
||||
}
|
||||
],
|
||||
)
|
||||
target = root / "docs/content/proof.toml"
|
||||
original = target.read_bytes()
|
||||
move = application_module.rename_noreplace_between_at
|
||||
raced = False
|
||||
|
||||
def race_detach(
|
||||
source_directory_fd: int,
|
||||
source: str,
|
||||
target_directory_fd: int,
|
||||
destination: str,
|
||||
) -> bool:
|
||||
nonlocal raced
|
||||
if source == target.name and destination.startswith(".detached-") and not raced:
|
||||
raced = True
|
||||
replacement = target.with_name(".foreign-delete")
|
||||
replacement.write_bytes(b"foreign replacement at delete detach\n")
|
||||
os.replace(replacement, target)
|
||||
return move(
|
||||
source_directory_fd,
|
||||
source,
|
||||
target_directory_fd,
|
||||
destination,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"docforge.application.rename_noreplace_between_at",
|
||||
side_effect=race_detach,
|
||||
),
|
||||
self.assertRaises(DocForgeError) as captured,
|
||||
):
|
||||
store.apply(
|
||||
changeset_id="delete-detach-race",
|
||||
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||
applier_id="alpha-editor",
|
||||
application=GenericCanonicalApplier(project).apply,
|
||||
)
|
||||
|
||||
self.assertTrue(raced)
|
||||
self.assertEqual("application_recovery_required", captured.exception.code)
|
||||
self.assertEqual(b"foreign replacement at delete detach\n", target.read_bytes())
|
||||
conflicts = captured.exception.details["conflicts"]
|
||||
retained = root / conflicts[0]["retained"]
|
||||
self.assertEqual(original, retained.read_bytes())
|
||||
self.assertFalse(
|
||||
(root / ".docforge/changesets/.state/delete-detach-race.json").exists()
|
||||
)
|
||||
|
||||
def test_create_rollback_detach_race_never_unlinks_foreign_target(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
proposal = store.register(
|
||||
"create-rollback-detach-race",
|
||||
[
|
||||
{
|
||||
"operation": "create",
|
||||
"node_id": "guide.created",
|
||||
"target_source": "docs/content/a-created.md",
|
||||
"metadata": self.new_metadata(),
|
||||
"content": "Approved content that publishes first.",
|
||||
"rationale": "Exercise create rollback detachment.",
|
||||
},
|
||||
{
|
||||
"operation": "update",
|
||||
"node_id": "guide.workflow",
|
||||
"metadata": {"summary": "Synthetic failing second publication."},
|
||||
"rationale": "Trigger rollback after create publication.",
|
||||
},
|
||||
],
|
||||
)
|
||||
target = root / "docs/content/a-created.md"
|
||||
publish = GenericCanonicalApplier._publish
|
||||
move = application_module.rename_noreplace_between_at
|
||||
publish_calls = 0
|
||||
raced = False
|
||||
|
||||
def fail_second(
|
||||
applier: GenericCanonicalApplier,
|
||||
publication: Any,
|
||||
) -> None:
|
||||
nonlocal publish_calls
|
||||
publish_calls += 1
|
||||
if publish_calls == 1:
|
||||
publish(applier, publication)
|
||||
return
|
||||
raise DocForgeError("application_failure", "Synthetic second publication failure")
|
||||
|
||||
def race_rollback_detach(
|
||||
source_directory_fd: int,
|
||||
source: str,
|
||||
target_directory_fd: int,
|
||||
destination: str,
|
||||
) -> bool:
|
||||
nonlocal raced
|
||||
if source == target.name and destination.startswith(".detached-") and not raced:
|
||||
raced = True
|
||||
replacement = target.with_name(".foreign-create-rollback")
|
||||
replacement.write_bytes(b"foreign replacement during create rollback\n")
|
||||
os.replace(replacement, target)
|
||||
return move(
|
||||
source_directory_fd,
|
||||
source,
|
||||
target_directory_fd,
|
||||
destination,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
GenericCanonicalApplier,
|
||||
"_publish",
|
||||
autospec=True,
|
||||
side_effect=fail_second,
|
||||
),
|
||||
mock.patch(
|
||||
"docforge.application.rename_noreplace_between_at",
|
||||
side_effect=race_rollback_detach,
|
||||
),
|
||||
self.assertRaises(DocForgeError) as captured,
|
||||
):
|
||||
store.apply(
|
||||
changeset_id="create-rollback-detach-race",
|
||||
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||
applier_id="alpha-editor",
|
||||
application=GenericCanonicalApplier(project).apply,
|
||||
)
|
||||
|
||||
self.assertTrue(raced)
|
||||
self.assertEqual("application_recovery_required", captured.exception.code)
|
||||
self.assertEqual(
|
||||
b"foreign replacement during create rollback\n",
|
||||
target.read_bytes(),
|
||||
)
|
||||
self.assertFalse(
|
||||
(root / ".docforge/changesets/.state/create-rollback-detach-race.json").exists()
|
||||
)
|
||||
|
||||
def test_rollback_never_clobbers_a_foreign_edit_and_retains_original_bytes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
|
|
@ -505,7 +676,8 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
target = root / "docs/content/workflow.md"
|
||||
target.chmod(0o640)
|
||||
target.chmod(0o6750)
|
||||
before = target.stat()
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
proposal = store.register(
|
||||
|
|
@ -528,8 +700,138 @@ class DocForgeChangesetTests(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(result["applied"])
|
||||
self.assertEqual(0o640, stat.S_IMODE(target.stat().st_mode))
|
||||
after = target.stat()
|
||||
self.assertEqual(0o6750, stat.S_IMODE(after.st_mode))
|
||||
self.assertEqual(before.st_uid, after.st_uid)
|
||||
self.assertEqual(before.st_gid, after.st_gid)
|
||||
self.assertEqual([], result["retained_recovery_files"])
|
||||
self.assertEqual("clean", result["application_recovery"]["status"])
|
||||
self.assertFalse(tuple((root / ".docforge/application").glob("transaction-*")))
|
||||
|
||||
def test_nested_creation_fsyncs_each_new_directory_and_parent_entry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
proposal = store.register(
|
||||
"nested-durable",
|
||||
[
|
||||
{
|
||||
"operation": "create",
|
||||
"node_id": "guide.nested",
|
||||
"target_source": "docs/content/nested/deeper/guide.md",
|
||||
"metadata": self.new_metadata(),
|
||||
"content": "Nested canonical content.",
|
||||
"rationale": "Prove durable nested-directory creation.",
|
||||
}
|
||||
],
|
||||
)
|
||||
real_fsync = os.fsync
|
||||
fsynced_directories: set[Path] = set()
|
||||
|
||||
def record_fsync(descriptor: int) -> None:
|
||||
try:
|
||||
path = Path(os.readlink(f"/proc/self/fd/{descriptor}"))
|
||||
if path.is_dir():
|
||||
fsynced_directories.add(path)
|
||||
except OSError:
|
||||
pass
|
||||
real_fsync(descriptor)
|
||||
|
||||
with mock.patch(
|
||||
"docforge.application.os.fsync",
|
||||
side_effect=record_fsync,
|
||||
):
|
||||
result = store.apply(
|
||||
changeset_id="nested-durable",
|
||||
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||
applier_id="alpha-editor",
|
||||
application=GenericCanonicalApplier(project).apply,
|
||||
)
|
||||
|
||||
self.assertTrue(result["applied"])
|
||||
for path in (
|
||||
root / "docs/content",
|
||||
root / "docs/content/nested",
|
||||
root / "docs/content/nested/deeper",
|
||||
):
|
||||
self.assertIn(path, fsynced_directories)
|
||||
|
||||
def test_post_commit_cleanup_failure_closes_proposal_with_recovery_record(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
project = Project.open(root)
|
||||
store = ChangesetStore(project, "alpha-editor")
|
||||
proposal = store.register(
|
||||
"cleanup-recovery",
|
||||
[
|
||||
{
|
||||
"operation": "update",
|
||||
"node_id": "guide.workflow",
|
||||
"metadata": {"summary": "Committed despite private cleanup failure."},
|
||||
"rationale": "Persist actionable post-commit recovery evidence.",
|
||||
}
|
||||
],
|
||||
)
|
||||
real_unlink = os.unlink
|
||||
failed = False
|
||||
|
||||
def fail_private_cleanup(
|
||||
path: str | bytes,
|
||||
*,
|
||||
dir_fd: int | None = None,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if (
|
||||
isinstance(path, str)
|
||||
and path.startswith("staged-")
|
||||
and dir_fd is not None
|
||||
and not failed
|
||||
):
|
||||
failed = True
|
||||
raise PermissionError("synthetic private cleanup failure")
|
||||
real_unlink(path, dir_fd=dir_fd)
|
||||
|
||||
with mock.patch(
|
||||
"docforge.application.os.unlink",
|
||||
side_effect=fail_private_cleanup,
|
||||
):
|
||||
result = store.apply(
|
||||
changeset_id="cleanup-recovery",
|
||||
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||
applier_id="alpha-editor",
|
||||
application=GenericCanonicalApplier(project).apply,
|
||||
)
|
||||
|
||||
self.assertTrue(failed)
|
||||
self.assertTrue(result["applied"])
|
||||
self.assertEqual("applied", result["lifecycle"]["status"])
|
||||
self.assertEqual(
|
||||
"cleanup_required",
|
||||
result["application_recovery"]["status"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"cleanup_required",
|
||||
result["lifecycle"]["application_recovery"]["status"],
|
||||
)
|
||||
retained = result["application_recovery"]["retained"]
|
||||
self.assertTrue(retained)
|
||||
lifecycle_path = root / ".docforge/changesets/.state/cleanup-recovery.json"
|
||||
lifecycle = json.loads(lifecycle_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
"cleanup_required",
|
||||
lifecycle["application_recovery"]["status"],
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as closed:
|
||||
store.apply(
|
||||
changeset_id="cleanup-recovery",
|
||||
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||
applier_id="alpha-editor",
|
||||
application=GenericCanonicalApplier(project).apply,
|
||||
)
|
||||
self.assertEqual("changeset_closed", closed.exception.code)
|
||||
|
||||
def test_changeset_rollback_fsyncs_the_parent_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue