fix: make _safe_write_json actually atomic with mkstemp + os.replace - #3971
Quratulain-bilal wants to merge 3 commits into
Conversation
Despite its name, _safe_write_json used write_text() which truncates the file before writing. A crash or power loss mid-write leaves a partial JSON file. Now uses tempfile.mkstemp + os.replace for atomic writes, matching the pattern used in _utils.py, shared_infra.py, and other safe-write utilities in the codebase.
There was a problem hiding this comment.
Pull request overview
Makes event configuration JSON writes atomic using a temporary file and os.replace.
Changes:
- Writes JSON to a same-directory temporary file.
- Cleans up temporary files on failure.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/events.py |
Adds atomic JSON replacement logic. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (1)
src/specify_cli/events.py:2024
mkstempcreates the staged file with mode0600, andos.replaceswaps that inode over the existing config. Unlike the previouswrite_textcall, this silently changes permissions (and potentially ownership) on every merge, which can lock other users or processes out of shared0640/0644config files. Preserve the existing file metadata before replacing it, as the atomic writer insrc/specify_cli/_utils.py:163-193does.
os.replace(tmp, dst)
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Balanced
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
mkstemp() creates files with mode 0600 (owner-only). The original write_text() used the default umask (typically 0644). Restore the expected permissions so other users/processes can read the file.
Use os.fchmod() to copy the destination file's permission bits to the staged temp file when the destination exists. Falls back to mkstemp's default 0600 when the destination is new.
|
Thanks. Two things before this can be reviewed, per CONTRIBUTING: (1) please disclose any AI assistance and its extent; (2) this needs a test — assert the file round-trips after a successful write, and that a failure mid-write leaves the original intact and cleans up the temp file. One correctness note from the review: |
There was a problem hiding this comment.
🟡 Changes recommended
Ownership, descriptor cleanup, and durability issues must be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/specify_cli/events.py:2026
os.replaceprovides atomic namespace replacement but not the power-loss guarantee stated in the PR: neither the temporary file nor the containing directory is synced, so a reboot can still expose missing or incomplete persisted data on filesystems without stronger ordering guarantees. If power-loss resilience is required, flush/fsync the temp before replacement and sync the directory afterward; otherwise narrow the PR's guarantee to process interruption.
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
os.replace(tmp, dst)
- Files reviewed: 1/1 changed files
- Comments generated: 2
- Review effort level: Balanced
| fd, tmp = tempfile.mkstemp( | ||
| dir=str(dst.parent), prefix=f".{dst.name}.", suffix=".tmp" | ||
| ) | ||
| try: | ||
| if dst.exists() and hasattr(os, "fchmod"): | ||
| os.fchmod(fd, dst.stat(follow_symlinks=False).st_mode & 0o7777) | ||
| with os.fdopen(fd, "w", encoding="utf-8") as f: | ||
| json.dump(data, f, indent=2) | ||
| f.write("\n") | ||
| os.replace(tmp, dst) | ||
| except BaseException: | ||
| try: | ||
| os.unlink(tmp) | ||
| except OSError: | ||
| pass | ||
| raise |
| fd, tmp = tempfile.mkstemp( | ||
| dir=str(dst.parent), prefix=f".{dst.name}.", suffix=".tmp" | ||
| ) | ||
| try: |
AI Assistance DisclosureI used AI assistance to identify that |
|
Closing in favor of consolidated PR #4588 which includes this change with tests and AI disclosure. |
Problem
Despite its name, _safe_write_json used write_text() which truncates the file before writing. A crash or power loss mid-write leaves a partial JSON file. This function is called by _merge_copilot_json, _merge_opencode_plugin_ref, etc., meaning all Copilot and OpenCode event config writes were non-atomic.
Fix
Now uses empfile.mkstemp + os.replace for atomic writes, matching the pattern used in _utils.py, shared_infra.py, and other safe-write utilities in the codebase.