|
14 | 14 | from ..core.models import ToolSpec |
15 | 15 |
|
16 | 16 |
|
17 | | -def atomic_write_text(path: str, content: str) -> None: |
18 | | - """Replace PATH's contents with CONTENT atomically. |
19 | | -
|
20 | | - THE single write path for every tool that rewrites a file (``Edit``, |
21 | | - ``Insert``, ``Write``, and the pure-Python diff applier). It lives |
22 | | - here, in the module every tool already imports, so the four callers |
23 | | - cannot drift apart -- and because ``base`` imports nothing from |
24 | | - ``tools``, putting it here is also the only placement that avoids a |
25 | | - circular import: ``filesystem.py`` re-imports ``edit``/``write``/ |
26 | | - ``insert`` at its bottom, so a helper defined *there* would make |
27 | | - ``edit`` import a half-initialised ``filesystem``. |
28 | | -
|
29 | | - A plain ``open(path, "w")`` TRUNCATES the file before writing, so a |
30 | | - write that fails partway through -- ENOSPC, a quota, an I/O error, |
31 | | - the process being killed -- left the file empty and the user's |
32 | | - content unrecoverable while the tool returned a tidy "Error: ..." |
33 | | - string. Instead the new content goes to a sibling temp file which is |
34 | | - then renamed over the target, mirroring the tmp-write + ``os.replace`` |
35 | | - discipline in ``SessionPersistence.save``. ``os.replace`` is atomic: |
36 | | - a reader sees either the complete old file or the complete new one, |
37 | | - and ANY failure before it leaves PATH untouched. The temp file is |
38 | | - removed on the error path. |
39 | | -
|
40 | | - Deliberate details: |
41 | | -
|
42 | | - - ``errors="surrogateescape"`` and ``newline=""`` match the tools' |
43 | | - read side, so invalid UTF-8 bytes (Latin-1, GBK, ...) round-trip |
44 | | - and the content is written byte-for-byte instead of having "\\n" |
45 | | - translated to ``os.linesep`` on Windows. |
46 | | - - The temp name carries a random suffix rather than a fixed ".tmp": |
47 | | - concurrent sub-agents can edit the same path, and a shared name |
48 | | - would let one writer rename the other's half-written file into |
49 | | - place. |
50 | | - - An existing file's permission bits are copied onto the |
51 | | - replacement, so editing an executable script does not drop its |
52 | | - ``+x``. A file that does not exist yet keeps the umask-derived |
53 | | - permissions a plain ``open(path, "w")`` would have produced. Only |
54 | | - the "no such file" case is tolerated: any other stat/chmod failure |
55 | | - propagates rather than silently shipping the wrong mode. |
56 | | -
|
57 | | - There is deliberately no ``fsync``: the failures this guards against |
58 | | - are process-level (a failed write, a kill, Ctrl-C), and in all of |
59 | | - them the rename never happens. Surviving a power loss in the window |
60 | | - between write and rename would need an fsync here and on the parent |
61 | | - directory, which ``SessionPersistence.save`` does not do either. |
62 | | -
|
63 | | - Consequences of replacing the file rather than rewriting it in place, |
64 | | - all inherent to the atomic-replace approach: |
65 | | -
|
66 | | - - PATH must already be symlink-resolved. ``os.replace`` onto a |
67 | | - symlink overwrites the LINK with a regular file, where an in-place |
68 | | - write would have followed it. Every caller resolves the real path |
69 | | - first (``Edit``/``Insert``/``Write`` via ``os.path.realpath`` on |
70 | | - entry, ``_apply_section`` on its resolved target). |
71 | | - - Hard links are broken: the new content lands on a new inode, so |
72 | | - other links to the old inode keep the old content. |
73 | | - - Owner/group, ACLs, SELinux labels and chattr flags are NOT carried |
74 | | - over (only the mode bits are). This matters mainly for an agent |
75 | | - running as root over files owned by someone else. |
76 | | - - On a crash between the write and the rename the temp file survives |
77 | | - as ``<name>.<hex>.tmp``; the original is still intact, but the |
78 | | - stray file is visible to Glob/Grep and to ``git status``. |
79 | | -
|
80 | | - Raises OSError on failure. Note this needs write permission on the |
81 | | - DIRECTORY, not just on the file -- the one behavioural difference |
82 | | - from a truncating in-place write (GNU ``patch``, used by ``Edit``'s |
83 | | - diff mode, has always had the same requirement). |
84 | | - """ |
85 | | - try: |
86 | | - mode: int | None = stat.S_IMODE(os.stat(path).st_mode) |
87 | | - except FileNotFoundError: |
88 | | - mode = None # brand-new file: keep the umask default from write_text |
89 | | - tmp = f"{path}.{uuid.uuid4().hex[:8]}.tmp" |
90 | | - try: |
91 | | - Path(tmp).write_text(content, encoding="utf-8", errors="surrogateescape", newline="") |
92 | | - if mode is not None: |
93 | | - os.chmod(tmp, mode) |
94 | | - os.replace(tmp, path) |
95 | | - except OSError: |
96 | | - with contextlib.suppress(OSError): |
97 | | - os.unlink(tmp) |
98 | | - raise |
99 | | - |
100 | | - |
101 | 17 | class PendingToolResult: |
102 | 18 | """Handle for an asynchronous tool result (mirrors ``:async t``). |
103 | 19 |
|
|
0 commit comments