Skip to content

Commit 27ce84f

Browse files
marcelsafinCopilot
andcommitted
fix: keep snapshot cleanup outside transaction outcomes
Report filesystem cleanup failures with the leaked snapshot path without masking committed success, provenance errors, incomplete rollback or snapshot capture failures. Reuse the same cleanup boundary during failed capture. Exercise both real managers through install/update and removal with sixteen failure-first cases. Assisted-by: GitHub Copilot (model: GPT-6 Astra, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 93cd405 commit 27ce84f

4 files changed

Lines changed: 93 additions & 7 deletions

File tree

‎docs/reference/bundles.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Re-resolves a bundle and **refreshes** its components through each primitive's u
6161

6262
Before refreshing or removing an owned component, the bundler snapshots its installed files and registry metadata. If a component operation or provenance write fails, it attempts to restore those local snapshots, including disabled state, user configuration, extension hook settings and pre-existing configuration backups, and generated command files and skill resources for current and previously active integrations, without downloading an older version. Previously absent outputs and configuration backups are also restored to absence. Custom steps are restored before dependent workflows. Recovery is best-effort and reports incomplete restoration; these temporary snapshots cover failures during the command, not process crashes or unrelated project files.
6363

64+
If temporary snapshot cleanup fails, a warning identifies the path for manual removal without changing the committed result or masking the original rollback error.
65+
6466
> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update` to re-apply every owned component at its pinned version.
6567
6668
## Remove a Bundle

‎src/specify_cli/bundler/models/snapshot.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Ephemeral installed-component state, never serialized into bundle records."""
22
from __future__ import annotations
33

4+
import logging
45
from dataclasses import dataclass, field
56
from pathlib import Path
67
from tempfile import TemporaryDirectory
78
from typing import Any
89

910
from .manifest import ComponentRef
1011

12+
logger = logging.getLogger(__name__)
13+
1114

1215
@dataclass
1316
class ArtifactSnapshot:
@@ -28,4 +31,10 @@ class ComponentSnapshot:
2831

2932
def close(self) -> None:
3033
if self.backup is not None:
31-
self.backup.cleanup()
34+
try:
35+
self.backup.cleanup()
36+
except OSError as exc:
37+
logger.warning(
38+
"Could not clean up rollback snapshot at %s; remove it manually: %s",
39+
self.backup.name, exc,
40+
)

‎src/specify_cli/bundler/services/primitives.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,19 +181,20 @@ def _snapshot_directory(
181181
) -> ComponentSnapshot:
182182
backup = TemporaryDirectory(prefix="speckit-bundle-rollback-")
183183
destination = Path(backup.name) / component.id
184-
try:
185-
shutil.copytree(directory, destination, symlinks=True)
186-
except OSError:
187-
backup.cleanup()
188-
raise
189-
return ComponentSnapshot(
184+
snapshot = ComponentSnapshot(
190185
component=_snapshot_ref(
191186
component, version=metadata.get("version"), metadata=metadata
192187
),
193188
metadata=copy.deepcopy(metadata),
194189
directory=destination,
195190
backup=backup,
196191
)
192+
try:
193+
shutil.copytree(directory, destination, symlinks=True)
194+
except OSError:
195+
snapshot.close()
196+
raise
197+
return snapshot
197198

198199

199200
def _snapshot_source(snapshot: ComponentSnapshot) -> Path:

‎tests/integration/test_bundler_state_rollback.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,80 @@ def fail_save(*_args):
233233
assert not backup.exists()
234234

235235

236+
@pytest.mark.parametrize("operation", ["refresh", "remove"])
237+
@pytest.mark.parametrize(
238+
"scenario", ["success", "save-failure", "rollback-failure", "capture-failure"]
239+
)
240+
def test_snapshot_cleanup_failure_preserves_transaction_outcome(
241+
installed_components, monkeypatch, caplog, operation, scenario,
242+
):
243+
import shutil
244+
from pathlib import Path
245+
from tempfile import TemporaryDirectory
246+
247+
project, kind, manager_type, installer, plan, metadata = installed_components
248+
original_record = records_path(project).read_bytes()
249+
cleanup = TemporaryDirectory.cleanup
250+
copy_tree = shutil.copytree
251+
attempted = []
252+
253+
def deny_cleanup(directory):
254+
if Path(directory.name).name.startswith("speckit-bundle-rollback-"):
255+
attempted.append(directory)
256+
raise PermissionError("snapshot cleanup denied")
257+
return cleanup(directory)
258+
259+
def fail_copy(source, destination, *args, **kwargs):
260+
if Path(source) == project / ".specify" / kind / "owned":
261+
raise PermissionError("snapshot copy refused")
262+
return copy_tree(source, destination, *args, **kwargs)
263+
264+
def fail_save(*_args):
265+
raise OSError("provenance write refused")
266+
267+
def fail_restore(*_args):
268+
raise OSError("restoration refused")
269+
270+
def change():
271+
if operation == "remove":
272+
return remove_bundle(project, "demo-bundle", installer)
273+
return install_bundle(project, plan(["owned", "keeper"]), installer, refresh=True)
274+
275+
monkeypatch.setattr(TemporaryDirectory, "cleanup", deny_cleanup)
276+
if scenario in ("save-failure", "rollback-failure"):
277+
monkeypatch.setattr("specify_cli.bundler.services.installer.save_records", fail_save)
278+
if scenario == "rollback-failure":
279+
monkeypatch.setattr(installer, "restore", fail_restore)
280+
if scenario == "capture-failure":
281+
monkeypatch.setattr(shutil, "copytree", fail_copy)
282+
try:
283+
if scenario == "success":
284+
assert change().changed
285+
assert (manager_type(project).registry.get("owned") is not None) == (
286+
operation == "refresh"
287+
)
288+
else:
289+
message = (
290+
"snapshot copy refused" if scenario == "capture-failure"
291+
else "provenance write refused"
292+
)
293+
with pytest.raises(BundlerError, match=message) as error:
294+
change()
295+
assert ("Rollback was incomplete" in str(error.value)) == (
296+
scenario == "rollback-failure"
297+
)
298+
assert records_path(project).read_bytes() == original_record
299+
if scenario != "rollback-failure":
300+
assert manager_type(project).registry.get("owned") == metadata
301+
finally:
302+
for directory in attempted:
303+
cleanup(directory)
304+
assert attempted
305+
assert "snapshot cleanup denied" in caplog.text
306+
for directory in attempted:
307+
assert directory.name in caplog.text
308+
309+
236310
@pytest.mark.parametrize("kind", ["steps", "workflows"])
237311
def test_dropped_component_restores_local_payload_and_exact_registry(
238312
tmp_path, monkeypatch, kind

0 commit comments

Comments
 (0)