Skip to content

Commit a80ac2b

Browse files
mnriemCopilot
andcommitted
Preserve rescued extension config across retry
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 02bc8d2 commit a80ac2b

2 files changed

Lines changed: 56 additions & 14 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
import copy
12+
import errno
1213
import hashlib
1314
import json
1415
import os
@@ -102,23 +103,46 @@ def _load_core_command_names() -> frozenset[str]:
102103
CORE_COMMAND_NAMES = _load_core_command_names()
103104

104105

106+
def _fsync_fd(fd: int) -> None:
107+
"""Sync a file descriptor, raising on real storage errors."""
108+
try:
109+
os.fsync(fd)
110+
except AttributeError:
111+
return
112+
except NotImplementedError:
113+
return
114+
except OSError as exc:
115+
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
116+
return
117+
raise
118+
119+
105120
def _fsync_directory(path: Path) -> None:
106-
"""Best-effort fsync for a directory path."""
121+
"""Sync a directory when the platform supports it."""
107122
if not path.exists():
108123
return
109124
try:
110125
dir_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
111-
except (AttributeError, OSError, NotImplementedError):
126+
except (AttributeError, NotImplementedError):
127+
return
128+
except OSError as exc:
129+
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
130+
return
112131
try:
113132
dir_fd = os.open(str(path), os.O_RDONLY)
114-
except (AttributeError, OSError, NotImplementedError):
133+
except (AttributeError, NotImplementedError):
115134
return
135+
except OSError as exc2:
136+
if exc2.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
137+
return
138+
raise
116139
try:
117-
os.fsync(dir_fd)
118-
except (AttributeError, OSError, NotImplementedError):
119-
pass
140+
_fsync_fd(dir_fd)
120141
finally:
121-
os.close(dir_fd)
142+
try:
143+
os.close(dir_fd)
144+
except OSError:
145+
pass
122146

123147

124148
class ExtensionError(Exception):
@@ -1515,10 +1539,7 @@ def install_from_directory(
15151539
staged.chmod(stat.S_IMODE(mode))
15161540
except (NotImplementedError, OSError):
15171541
pass # Best-effort; chmod may not be supported on all platforms.
1518-
try:
1519-
os.fsync(fd)
1520-
except (AttributeError, OSError, NotImplementedError):
1521-
pass
1542+
_fsync_fd(fd)
15221543
finally:
15231544
os.close(fd)
15241545
# Write the completion marker only after every staged file is
@@ -1529,9 +1550,7 @@ def install_from_directory(
15291550
0o600,
15301551
)
15311552
try:
1532-
os.fsync(marker_fd)
1533-
except (AttributeError, OSError, NotImplementedError):
1534-
pass
1553+
_fsync_fd(marker_fd)
15351554
finally:
15361555
os.close(marker_fd)
15371556
_fsync_directory(rescue_staging_dir)
@@ -1566,11 +1585,27 @@ def _restore_stranded_config_file(
15661585
) as tmp:
15671586
tmp_path = Path(tmp.name)
15681587
tmp.write(content)
1588+
tmp.flush()
1589+
_fsync_fd(tmp.fileno())
15691590
try:
15701591
tmp_path.chmod(stat.S_IMODE(preserved_mode))
15711592
except (NotImplementedError, OSError):
15721593
pass # Best-effort; chmod may not be supported on all platforms.
15731594
os.replace(tmp_path, target)
1595+
try:
1596+
target_fd = os.open(str(target), os.O_RDONLY)
1597+
except (AttributeError, OSError, NotImplementedError):
1598+
target_fd = None
1599+
try:
1600+
if target_fd is not None:
1601+
_fsync_fd(target_fd)
1602+
finally:
1603+
if target_fd is not None:
1604+
try:
1605+
os.close(target_fd)
1606+
except OSError:
1607+
pass
1608+
_fsync_directory(target.parent)
15741609
except BaseException:
15751610
if tmp_path is not None and tmp_path.exists():
15761611
tmp_path.unlink()

tests/test_extensions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from unittest.mock import MagicMock
2323

2424
from tests.conftest import strip_ansi
25+
import specify_cli.extensions as _ext_module
2526
from specify_cli.extensions import (
2627
CatalogEntry,
2728
CORE_COMMAND_NAMES,
@@ -1408,6 +1409,12 @@ def flaky_copytree(*args, **kwargs):
14081409
assert (staging_dir / ".rescue-complete").exists()
14091410
assert (staging_dir / "test-ext-config.yml").exists()
14101411

1412+
# Corrupt the rollback-restored copy to prove the next retry must rely
1413+
# on the durable staging backup, not whatever was written to dest_dir
1414+
# by the earlier failed install attempt.
1415+
config_file.write_text("model: wrong-model\n", encoding="utf-8")
1416+
assert config_file.read_bytes() != original_bytes
1417+
14111418
manifest = manager.install_from_directory(
14121419
extension_dir, "0.1.0", register_commands=False
14131420
)

0 commit comments

Comments
 (0)