refactor(file_open): PR 14.5 — atomic write helper (H1 first-PR scope)

PYTHON_THINNING_PLAN §5 PR 14.5. BACKLOG H1 first-PR scope의 *전제* —
full Rust transaction (read+guard+write 한 함수)는 PR 14.5b 후속.

문제:
- 기존 ``open_remote_file_into_local_cache``는 multi-step:
  (1) execute_remote_read_file (helper bridge)
  (2) evaluate_open_file (guard policy)
  (3) parent.mkdir(...)
  (4) target.write_bytes(...)
- (4) 도중 인터프리터가 죽으면 ``target``이 *truncated bytes*로 존재.
  다른 reader(LSP, ruff 등)가 partial state를 보고 잘못된 결과 반환.
- shipping-operator 4-team 토론 시 v0.6.12 #13/#14 silent corruption
  영역으로 지목된 path.

산출물:
- ``_atomic_write_bytes(target, body)`` helper 신설 (~25 LOC):
  - tempfile.mkstemp으로 sibling tempfile 생성 (같은 parent → rename
    atomic).
  - write 후 ``Path.replace``로 atomic rename (POSIX rename(2),
    Windows MoveFileEx — 둘 다 same-volume atomic).
  - BaseException 시 best-effort tempfile cleanup (signal/error 시
    .NAME.XXX.part 잔재 방지).
- ``open_remote_file_into_local_cache`` write phase가 helper 호출로
  교체. 다른 단계(read / guard / outcome)는 변경 없음.

H1 first-PR scope 충족:
- 전제 #1: write phase가 partial-state 노출 0. 
- 후속 (PR 14.5b): read+guard+write를 *한 Rust 함수*로 (broker request
  invocation까지 Rust 측 통합). 회귀 표면 매우 크므로 분할.

테스트: sublime/tests 1313 그린 (test_ssh_file_transport / test_cmd_save /
test_integration_remote_file_ops 121건 비트 동일).
boundary lint 위반 0건.

boundary-claim:
  removes:
    - sublime/sessions/ssh_file_transport.py:2145-2146  # 비-atomic mkdir+write
  delete-count: 2 (atomic-write helper 25줄로 교체)
  ban-list: 'H1 first-PR scope 전제 — full Rust transaction PR 14.5b'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 09:13:38 +09:00
parent 74b9fef98e
commit 9d6feea697

View File

@@ -2082,6 +2082,40 @@ def execute_remote_write_file(
)
def _atomic_write_bytes(target: Path, body: bytes) -> None:
"""Write ``body`` to ``target`` atomically (tempfile + rename).
H1 first-PR scope (PR 14.5): the previous flow did
``parent.mkdir(...); target.write_bytes(...)`` — a partial-state
window where ``target`` could exist with truncated bytes if the
interpreter died between ``open()`` and ``close()``. Writing to a
sibling tempfile and atomically renaming closes that window: any
observer either sees the *prior* contents (or absence) or the
*complete* new bytes, never a partial.
"""
target.parent.mkdir(parents=True, exist_ok=True)
# Same parent so ``rename`` is a same-filesystem atomic op (POSIX
# ``rename(2)``; on Windows ``Path.replace`` falls back to
# ``MoveFileEx`` which is atomic for same-volume targets).
fd, tmp_str = tempfile.mkstemp(
prefix="." + target.name + ".", suffix=".part", dir=str(target.parent)
)
tmp_path = Path(tmp_str)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(body)
tmp_path.replace(target)
except BaseException:
# On any failure (write error, signal, etc.) do best-effort cleanup
# of the tempfile so the cache directory does not accumulate
# ``.NAME.XXXXXX.part`` debris.
try:
tmp_path.unlink()
except FileNotFoundError:
pass
raise
def open_remote_file_into_local_cache(
host_alias: str,
*,
@@ -2092,6 +2126,11 @@ def open_remote_file_into_local_cache(
) -> OpenFileResult:
"""Fetch remote bytes over SSH, run open guardrails, and write the local cache file.
Wave 2 PR 14.5 (H1 first-PR scope): write phase uses
:func:`_atomic_write_bytes` so a crash between read and write cannot
leave a half-written cache file. Full Rust transaction (read +
guardrail + write inside one Rust call) lands as PR 14.5b.
Transport failures are surfaced as ``OpenOutcome.TRANSPORT_ERROR`` so callers
can stay UI-free while still distinguishing policy blocks from SSH issues.
Missing remote paths (``ENOENT`` / ``lstat_failed``) return
@@ -2142,8 +2181,7 @@ def open_remote_file_into_local_cache(
)
if opened.outcome is not OpenOutcome.OK:
return opened
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
local_cache_path.write_bytes(read_result.body)
_atomic_write_bytes(local_cache_path, read_result.body)
return OpenFileResult(
outcome=opened.outcome,
local_cache_path=opened.local_cache_path,