Land the crash-recoverable managed-file transaction engine (journal, staging, commit, restart recovery, opaque snapshots, private artifacts, Windows DACL hardening) and the Kiro IDE 1.0 workspace hook plugin with legacy migration. Repair five defects that kept the suite red (426 failures to zero): - Anchor the transaction state directory at ~/.elydora/transactions instead of the shared system temp directory. The recovery journal now shares fate with the runtimes it repairs, survives temp cleaners, and isolates naturally under per-test fake homes. - Skip pending-transaction recovery when no state directory exists so rejected installs never create runtime directories. - Create the runtime root's parent before hardening it, matching the Go SDK's expand behavior for fresh homes. - Stop using file timestamps as identity evidence: Windows exposes mtime/ctime with delayed cross-handle visibility. Reads now prove stability through a verification re-read; snapshots compare device, inode, size, and SHA-256. - Stop comparing synthetic Windows permission bits across stat boundaries: path stats derive execute bits from file extensions while handle stats do not, which falsely flagged every managed .cmd wrapper. Snapshots store raw modes for restoration and compare portable bits. Reservation cleanup now verifies content before discarding so a concurrent writer's file at a rollback path is preserved, and the install-failure tests assert the precise contract: no agent runtime residue, with the durable state directory allowed to remain. Gates: pytest 807 passed, mypy clean, pip check clean, wheel builds.
323 lines
10 KiB
Python
323 lines
10 KiB
Python
"""Deterministic recovery of a transaction interrupted by process exit."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Dict, Optional
|
|
|
|
from ._durable_file import atomic_replace
|
|
from ._pinned_directory import PinnedDirectory
|
|
from ._private_artifact import QuarantinedFile
|
|
from ._transaction_journal import (
|
|
FileFingerprint,
|
|
JournalRecord,
|
|
TransactionJournal,
|
|
clear_journal,
|
|
inspect_fingerprint,
|
|
load_journal,
|
|
write_journal,
|
|
)
|
|
from ._transaction_lock import serialized_transactions, transaction_state_path
|
|
|
|
|
|
def _directory_key(path: str) -> str:
|
|
return os.path.normcase(os.path.abspath(path))
|
|
|
|
|
|
def _open_directories(journal: TransactionJournal) -> Dict[str, PinnedDirectory]:
|
|
directories: Dict[str, PinnedDirectory] = {}
|
|
try:
|
|
for record in journal.records:
|
|
key = _directory_key(record.directory)
|
|
current = directories.get(key)
|
|
if current is not None:
|
|
if current.snapshot != record.directory_snapshot:
|
|
raise OSError(
|
|
f"Conflicting directory identities in transaction journal: "
|
|
f"{record.directory}"
|
|
)
|
|
continue
|
|
directories[key] = PinnedDirectory.open(
|
|
record.directory,
|
|
f"{record.label} recovery directory",
|
|
record.directory_snapshot,
|
|
)
|
|
return directories
|
|
except Exception:
|
|
_close_directories(directories)
|
|
raise
|
|
|
|
|
|
def _close_directories(directories: Dict[str, PinnedDirectory]) -> None:
|
|
failures = []
|
|
for directory in reversed(list(directories.values())):
|
|
try:
|
|
directory.close()
|
|
except OSError as error:
|
|
failures.append(str(error))
|
|
if failures:
|
|
raise OSError("; ".join(failures))
|
|
|
|
|
|
def _directory_for(
|
|
record: JournalRecord,
|
|
directories: Dict[str, PinnedDirectory],
|
|
) -> PinnedDirectory:
|
|
directory = directories.get(_directory_key(record.directory))
|
|
if directory is None:
|
|
raise OSError(f"Missing recovery directory for {record.label}")
|
|
return directory
|
|
|
|
|
|
def _remove_owned_file(
|
|
directory: PinnedDirectory,
|
|
path: Optional[str],
|
|
expected_identity: Optional[FileFingerprint],
|
|
label: str,
|
|
) -> None:
|
|
if path is None or expected_identity is None:
|
|
return
|
|
current = inspect_fingerprint(directory, path, label)
|
|
if current is None:
|
|
return
|
|
if (
|
|
current.device != expected_identity.device
|
|
or current.inode != expected_identity.inode
|
|
):
|
|
raise OSError(f"{label} changed before recovery cleanup: {path}")
|
|
captured = QuarantinedFile.capture(
|
|
directory,
|
|
path,
|
|
label,
|
|
require_parent_path=False,
|
|
)
|
|
captured_current = inspect_fingerprint(
|
|
captured.container,
|
|
captured.container.path_for("entry"),
|
|
label,
|
|
)
|
|
if (
|
|
captured_current is None
|
|
or captured_current.device != expected_identity.device
|
|
or captured_current.inode != expected_identity.inode
|
|
):
|
|
preserved = captured.preserve()
|
|
raise OSError(
|
|
f"{label} changed at recovery cleanup boundary; preserved at {preserved}"
|
|
)
|
|
captured.discard()
|
|
directory.sync()
|
|
|
|
|
|
def _cleanup_record(
|
|
record: JournalRecord,
|
|
directories: Dict[str, PinnedDirectory],
|
|
) -> None:
|
|
directory = _directory_for(record, directories)
|
|
_remove_owned_file(
|
|
directory,
|
|
record.temporary,
|
|
record.temporary_identity,
|
|
f"staged {record.label}",
|
|
)
|
|
_remove_owned_file(
|
|
directory,
|
|
record.rollback,
|
|
record.rollback_identity,
|
|
f"{record.label} rollback",
|
|
)
|
|
|
|
|
|
def _restore_original(
|
|
record: JournalRecord,
|
|
directory: PinnedDirectory,
|
|
) -> None:
|
|
original = record.original
|
|
rollback_path = record.rollback
|
|
rollback_identity = record.rollback_identity
|
|
if original is None or rollback_path is None or rollback_identity is None:
|
|
raise OSError(f"Missing rollback data for {record.label}")
|
|
rollback = inspect_fingerprint(
|
|
directory,
|
|
rollback_path,
|
|
f"{record.label} rollback",
|
|
)
|
|
if (
|
|
rollback is None
|
|
or rollback.device != rollback_identity.device
|
|
or rollback.inode != rollback_identity.inode
|
|
or not original.same_contents(rollback)
|
|
):
|
|
raise OSError(
|
|
f"{record.label} rollback changed during restart recovery: "
|
|
f"{rollback_path}"
|
|
)
|
|
if rollback.mode != original.mode:
|
|
directory.chmod_file(
|
|
directory.name_for(rollback_path),
|
|
original.mode,
|
|
(rollback.device, rollback.inode),
|
|
)
|
|
atomic_replace(directory, rollback_path, record.target)
|
|
restored = inspect_fingerprint(directory, record.target, record.label)
|
|
if not original.same_visible(restored):
|
|
raise OSError(
|
|
f"{record.label} restoration failed during restart recovery: "
|
|
f"{record.target}"
|
|
)
|
|
|
|
|
|
def _matches_original_state(
|
|
record: JournalRecord,
|
|
current: Optional[FileFingerprint],
|
|
) -> bool:
|
|
original = record.original
|
|
if original is None or current is None or not original.same_visible(current):
|
|
return False
|
|
if original.same_exact(current):
|
|
return True
|
|
rollback = record.rollback_identity
|
|
return (
|
|
rollback is not None
|
|
and current.device == rollback.device
|
|
and current.inode == rollback.inode
|
|
)
|
|
|
|
|
|
def _remove_created_target(
|
|
record: JournalRecord,
|
|
directory: PinnedDirectory,
|
|
current: FileFingerprint,
|
|
) -> None:
|
|
expected = record.next
|
|
if expected is None or not expected.same_exact(current):
|
|
raise OSError(
|
|
f"{record.label} changed before creation recovery: {record.target}"
|
|
)
|
|
captured = QuarantinedFile.capture(directory, record.target, record.label)
|
|
captured_current = inspect_fingerprint(
|
|
captured.container,
|
|
captured.container.path_for("entry"),
|
|
record.label,
|
|
)
|
|
if not expected.same_exact(captured_current):
|
|
try:
|
|
captured.restore(record.target)
|
|
except Exception as error:
|
|
preserved = captured.preserve() if captured.active else record.target
|
|
raise OSError(
|
|
f"{record.label} changed at creation recovery boundary; "
|
|
f"preserved at {preserved}: {error}"
|
|
) from error
|
|
raise OSError(
|
|
f"{record.label} changed at creation recovery boundary: {record.target}"
|
|
)
|
|
captured.discard()
|
|
directory.sync()
|
|
|
|
|
|
def _recover_record(
|
|
record: JournalRecord,
|
|
directories: Dict[str, PinnedDirectory],
|
|
) -> None:
|
|
directory = _directory_for(record, directories)
|
|
current = inspect_fingerprint(directory, record.target, record.label)
|
|
original = record.original
|
|
next_value = record.next
|
|
if original is None:
|
|
if current is None:
|
|
return
|
|
_remove_created_target(record, directory, current)
|
|
return
|
|
if _matches_original_state(record, current):
|
|
return
|
|
if next_value is None:
|
|
if current is not None:
|
|
raise OSError(
|
|
f"{record.label} changed after transactional removal: {record.target}"
|
|
)
|
|
_restore_original(record, directory)
|
|
return
|
|
if current is None or not next_value.same_exact(current):
|
|
raise OSError(
|
|
f"{record.label} changed after atomic replacement; original content "
|
|
f"preserved at {record.rollback}: {record.target}"
|
|
)
|
|
_restore_original(record, directory)
|
|
|
|
|
|
def _verify_committed_record(
|
|
record: JournalRecord,
|
|
directories: Dict[str, PinnedDirectory],
|
|
) -> None:
|
|
directory = _directory_for(record, directories)
|
|
current = inspect_fingerprint(directory, record.target, record.label)
|
|
if record.next is None:
|
|
if current is not None:
|
|
raise OSError(
|
|
f"{record.label} changed after committed removal: {record.target}"
|
|
)
|
|
return
|
|
if not record.next.same_exact(current):
|
|
raise OSError(
|
|
f"{record.label} changed after committed transaction: {record.target}"
|
|
)
|
|
|
|
|
|
def recover_pending_locked() -> None:
|
|
"""Recover the active journal while the caller owns the transaction lock."""
|
|
journal = load_journal()
|
|
if journal is None:
|
|
return
|
|
directories = _open_directories(journal)
|
|
try:
|
|
if journal.state == "preparing":
|
|
for record in journal.records:
|
|
current = inspect_fingerprint(
|
|
_directory_for(record, directories),
|
|
record.target,
|
|
record.label,
|
|
)
|
|
if record.original is None:
|
|
if current is not None:
|
|
raise OSError(
|
|
f"{record.label} appeared during transaction preparation: "
|
|
f"{record.target}"
|
|
)
|
|
elif not record.original.same_exact(current):
|
|
raise OSError(
|
|
f"{record.label} changed during transaction preparation: "
|
|
f"{record.target}"
|
|
)
|
|
elif journal.state in {"committing", "recovering"}:
|
|
if journal.state != "recovering":
|
|
journal = journal.with_state("recovering")
|
|
write_journal(journal)
|
|
for record in reversed(journal.records):
|
|
_recover_record(record, directories)
|
|
elif journal.state == "committed":
|
|
for record in journal.records:
|
|
_verify_committed_record(record, directories)
|
|
else:
|
|
raise ValueError(f"Unsupported transaction state: {journal.state}")
|
|
for record in journal.records:
|
|
_cleanup_record(record, directories)
|
|
clear_journal()
|
|
finally:
|
|
_close_directories(directories)
|
|
|
|
|
|
def recover_pending_transactions() -> None:
|
|
"""Serialize and recover any transaction left by a terminated process.
|
|
|
|
A missing state directory proves no journal exists, so this returns without
|
|
creating runtime directories for commands that may still reject their input.
|
|
"""
|
|
if not os.path.isdir(transaction_state_path()):
|
|
return
|
|
with serialized_transactions():
|
|
recover_pending_locked()
|
|
|
|
|
|
__all__ = ["recover_pending_locked", "recover_pending_transactions"]
|